The .env cascade
The framework loads a small cascade of .env files once at boot, before
any Config::from_env runs. The cascade follows dotenv-flow conventions:
most specific wins, real process env wins over every file, and tests stay
hermetic.
The cascade is parsed once per process into an in-crate map, and every
ConfigService reader consults it under
the real process environment. Nothing in the running app mutates std::env —
set_var is unsound against a concurrent read on another thread, so config
resolution stays side-effect-free by design.
The order
Section titled “The order”real env > .env.<environment>.local > .env.local > .env.<environment> > .envRead left-to-right as decreasing precedence: a key set in the real
process environment overrides every file; among files, .env.<env>.local
wins, .env loses. Set-if-absent semantics make the first writer win, so
loading “most specific first” implements the documented order without a
second pass.
| Layer | Committed? | Purpose |
|---|---|---|
| Real process env | n/a | Deployment overrides, secrets injected by the orchestrator |
.env.<env>.local | gitignored | Per-machine secrets for one environment (e.g. local prod creds) |
.env.local | gitignored | Per-machine secrets shared across environments |
.env.<env> | committed | Non-secret defaults for one environment |
.env | committed | Non-secret defaults shared across environments |
The convention: anything ending in .local is gitignored, everything
else is committed.
The active environment
Section titled “The active environment”The <environment> segment is the variant of Environment, read from
the reserved NESTRS_ENV variable. This is the one framework variable
outside the NESTRS_<DOMAIN>__<KEY> scheme — it selects which .env
files to load, so it must come from the real process environment, not a
.env file.
pub enum Environment { Development, Test, Staging, Production,}NESTRS_ENV | Variant |
|---|---|
| unset, anything unrecognized | Development |
test | Test |
staging / stage | Staging |
production / prod | Production |
The default is Development. Tests default to Test (the test harness
sets it before any builder runs).
Test mode is hermetic
Section titled “Test mode is hermetic”Under Environment::Test, the cascade skips .env.local. A
developer’s personal secrets (database URLs, OAuth client IDs, API
tokens) cannot leak into a test run. The committed .env.test still
loads — that’s where shared test defaults belong.
.env.test.local ← loaded (per-machine test overrides).env.local ← skipped under Test.env.test ← loaded.env ← loadedIf a test sets NESTRS_ENV to something else explicitly (CI asserting
prod behavior, for instance), the cascade follows that.
When the load happens
Section titled “When the load happens”Exactly once per process, on the first call that needs it:
ConfigModule::for_root()parses it during the collect phase, before any factory runs. This is the expected path.- The default
EnvSourceparses it on its firstget. A customConfigSourcenever does — it supplies its own values. Environment::init()parses it and merges it intostd::env(set-if-absent, so the real environment still wins). Everything the scaffoldedmaindoes afterwards therefore sees the cascade, including the many consumers that only knowstd::env::var.
That last one is why the scaffold calls it on line one of main:
let _environment = Environment::init();Without it, NESTRS_LOG / NESTRS_LOG_FORMAT / NESTRS_LOG_SOURCE_LOCATION
in .env.development would be inert — the logging setup reads the process
environment directly, long before a ConfigService exists. The same goes for
any binary of yours that reads std::env::var at startup.
Every layer is optional: a file that does not exist contributes nothing, which
is what lets the same cascade run unchanged in a container with no .env at
all.
Variable naming
Section titled “Variable naming”Inside the cascade and in real env vars, every configurable field follows the same scheme:
NESTRS_<NAMESPACE>__<KEY>NESTRS_— fixed prefix.<NAMESPACE>— the namespace from#[config(namespace = "…")], uppercased.__— a double underscore separator (single underscores are reserved for word boundaries inside keys).<KEY>— the field name, uppercased. The macro doesn’t enforce a specific casing —from_envcallsenv.get("…")with whatever string you write.
Examples:
NESTRS_DATABASE__URLNESTRS_DATABASE__MAX_CONNECTIONSNESTRS_ISSUER__CLIENTSNESTRS_HTTP__PORTA feature reads only its own namespace from from_env. To borrow a
sibling variable (rare — see own > borrowed > code default on the
index), call env_var("NESTRS_<OTHER>__<KEY>")
directly.
Quoting rules inside .env files
Section titled “Quoting rules inside .env files”The minimal parser handles three forms:
# Unquoted: as-is, no escapingURL=postgres://localhost/app
# Double-quoted: expands \n \t \r \\ \"JWT_PUBLIC_KEY="-----BEGIN-----\nMIIB...\n-----END-----"
# Single-quoted: literal, no escape expansionRAW='a\nb' # value is the four characters: a, backslash, n, bLines starting with # are comments. Empty lines are skipped. An
export prefix is tolerated for shell compatibility. Empty keys and
lines without = are silently dropped.
The double-quoted escape set exists for one reason: PEM keys fit on a
single line with \n instead of literal newlines.
Going further
Section titled “Going further”- Configuration overview — the typed-struct contract.
- Alternative sources — replace
EnvSourcewith Vault or a K8s ConfigMap. - Overriding in tests — seed pinned values,
use
figment::Jailfor environment isolation.
Built by YV17labs