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.
A single call merges them into std::env exactly once per process,
guarded by a Once. After that, every ConfigService
reader sees the same merged environment.
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 merge happens
Section titled “When the merge happens”Exactly once per process, on the first call that needs it:
ConfigModule::for_root()triggers it during the collect phase, before any factory runs. This is the expected path.Environment::init()triggers it. Call this at the top ofmainif something reads the env outside the DI graph (e.g. an OpenTelemetry initializer that runs beforeApp::builder()).- The default
EnvSourcetriggers it on its firstget. A customConfigSourcenever triggers it — the process env stays untouched.
The guard is process-wide, so multiple calls are safe. The cascade is best-effort: missing files are silently skipped.
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.