The .env cascade
Which .env files are loaded, in what order, and how the active Environment selects them.
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 |
A value pinned in code (Module::for_root(cfg)) slots in between: below the
real process env, above every file. The reasoning and the full chain live with
the dual-path rule.
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. Writing it into one is a boot error naming the fix, the
same refusal NESTRS_ENV_PREFIX gets: the value arrives after the
cascade it names was already chosen, and it would arm development-only
affordances from a committed file. A file may only restate a value the
process actually carries.
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:
- The default
EnvSourceparses it on its firstget— so the first config read of the boot, in the factory phase. A customConfigSourcenever does; it supplies its own values. ConfigModule::for_root()does not parse it. Its collect readsNESTRS_ENVfrom the real environment and registers the resultingArc<Environment>, which is what decides which files a later parse reads.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_SEAORM__URLNESTRS_SEAORM__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. - Env-var reference — every
NESTRS_*variable, by namespace.