Skip to content

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::envset_var is unsound against a concurrent read on another thread, so config resolution stays side-effect-free by design.

real env > .env.<environment>.local > .env.local > .env.<environment> > .env

Read 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.

LayerCommitted?Purpose
Real process envn/aDeployment overrides, secrets injected by the orchestrator
.env.<env>.localgitignoredPer-machine secrets for one environment (e.g. local prod creds)
.env.localgitignoredPer-machine secrets shared across environments
.env.<env>committedNon-secret defaults for one environment
.envcommittedNon-secret defaults shared across environments

The convention: anything ending in .local is gitignored, everything else is committed.

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_ENVVariant
unset, anything unrecognizedDevelopment
testTest
staging / stageStaging
production / prodProduction

The default is Development. Tests default to Test (the test harness sets it before any builder runs).

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 ← loaded

If a test sets NESTRS_ENV to something else explicitly (CI asserting prod behavior, for instance), the cascade follows that.

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 EnvSource parses it on its first get. A custom ConfigSource never does — it supplies its own values.
  • Environment::init() parses it and merges it into std::env (set-if-absent, so the real environment still wins). Everything the scaffolded main does afterwards therefore sees the cascade, including the many consumers that only know std::env::var.

That last one is why the scaffold calls it on line one of main:

apps/blog/src/main.rs
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.

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_env calls env.get("…") with whatever string you write.

Examples:

NESTRS_DATABASE__URL
NESTRS_DATABASE__MAX_CONNECTIONS
NESTRS_ISSUER__CLIENTS
NESTRS_HTTP__PORT

A 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.

The minimal parser handles three forms:

.env
# Unquoted: as-is, no escaping
URL=postgres://localhost/app
# Double-quoted: expands \n \t \r \\ \"
JWT_PUBLIC_KEY="-----BEGIN-----\nMIIB...\n-----END-----"
# Single-quoted: literal, no escape expansion
RAW='a\nb' # value is the four characters: a, backslash, n, b

Lines 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.

Built by YV17labs