Loading Per-Environment .env Files Without Leaks
You run npm run dev, expecting to hit the local Postgres container, and the app connects to the production database instead — a DATABASE_URL that only ever belonged in .env.production bled into the development boot. This guide lives under dotenv and configuration management inside the environment sync, secrets and CI parity baseline, and it fixes the specific class of failure where a per-mode file is loaded in the wrong mode, or a secret sits in a shared file every mode inherits.
The layered .env convention popularized by Vite, Create React App, and dotenv-flow is powerful precisely because it lets a committed default, a machine-local override, and a mode-specific value coexist. That same layering is what leaks a secret when the load order is implicit, when a glob merges every mode at once, or when a *.local file escapes .gitignore. The fix is to make the load order a single deterministic list, keep secrets out of every committed file, and gate both facts in CI. Once that is in place, running in one mode can never surface a value that belongs to another.
Diagnostic
Reproduce the leak with the loader most teams write first — a shell loop that sources every file matching .env.*. It looks convenient and is the root of the problem, because it merges .env.production into a development shell with no regard for the active mode.
#!/usr/bin/env bash
set -euo pipefail
# NAIVE loader: sources EVERY .env.* file regardless of NODE_ENV
export NODE_ENV=development
set -a
for f in .env .env.*; do
[ -f "$f" ] && . "$f"
done
set +a
node -e 'console.log("DATABASE_URL=" + process.env.DATABASE_URL)'
Expected BAD output (a development boot resolving a production value):
DATABASE_URL=postgresql://app:[email protected]:5432/app
The mode is development, yet the connection string points at db.prod.internal. The glob .env.* expanded to include .env.production, sourced it last (alphabetically it sorts after .env.development), and the production value won. Nothing in the loop knows which mode you intended, so the file that happens to sort last silently decides the result.
To see exactly which file supplied each key, resolve the layers deterministically and print the provenance. Save the loader below as scripts/load-env.mjs; it is the same code you will keep, run here in report mode:
#!/usr/bin/env node
// scripts/load-env.mjs — deterministic per-mode loader with provenance
import { existsSync, readFileSync } from 'node:fs';
import { parse } from 'dotenv';
const mode = process.env.NODE_ENV || 'development';
// Lowest priority first; later files override earlier ones key by key.
// .env.local is intentionally skipped in test mode for reproducible runs.
const files = [
'.env',
`.env.${mode}`,
...(mode === 'test' ? [] : ['.env.local']),
`.env.${mode}.local`,
];
const merged = {};
const source = {};
for (const file of files) {
if (!existsSync(file)) continue;
const parsed = parse(readFileSync(file));
for (const [key, value] of Object.entries(parsed)) {
merged[key] = value;
source[key] = file;
}
}
const arg = process.argv[2];
if (arg === '--keys') {
console.log(Object.keys(merged).sort().join('\n'));
} else if (arg === '--provenance') {
for (const key of Object.keys(merged).sort()) {
console.error(`${key.padEnd(20)} <- ${source[key]}`);
}
} else {
for (const [key, value] of Object.entries(merged)) {
console.log(`export ${key}=${JSON.stringify(value)}`);
}
}
#!/usr/bin/env bash
set -euo pipefail
NODE_ENV=development node scripts/load-env.mjs --provenance
The provenance report names the exact file behind every key, so a value arriving from .env.production during a development run is impossible to miss. This is the command you reach for whenever a variable is "wrong" — it converts a guessing game into a one-line answer.
Root cause
Three failures compound into a leak. First, the load order is implicit — a glob or an undocumented loop leaves the winning file to chance instead of to a named priority list. Second, a real secret sits in a file that every mode reads (a shared .env or a committed .env.production), so isolating modes at load time does nothing because the value was never isolated at rest. Third, a *.local file that should never be committed slips past .gitignore and travels to another machine or a CI runner where it does not belong. Fix any one of these in isolation and the other two still leak; the safe workflow closes all three at once.
The layered convention assigns each file a single job, and the leak is almost always a value placed in the wrong tier. .env holds defaults identical in every environment and is committed — so it must contain no secret. .env.<mode> holds non-secret values specific to a mode (a public API base URL for staging, a log level for production) and is also committed. .env.local holds a developer's machine-local overrides, is git-ignored, and — critically — is skipped in test mode so a personal setting never makes a test pass or fail differently than it does in CI. .env.<mode>.local is where a real secret lives: git-ignored, mode-specific, present only on the machine that needs it. Precedence runs highest to lowest as .env.<mode>.local › .env.local › .env.<mode> › .env.
Note that this layered convention treats .env as a committed, secret-free defaults file, which is the opposite of treating .env as the one concrete per-host file you never commit. Both conventions are valid; what matters is that the team picks one and enforces it. If you rely on a tracked .env.example template and an ignored concrete .env instead, the loading rules here still apply — read them alongside resolving env precedence conflicts across Compose files, which covers how Compose itself layers the same files at container start.
Resolution
- Write the canonical file set. Commit the shared and per-mode non-secret files; put every real credential only in the ignored
*.localfiles.
#!/usr/bin/env bash
set -euo pipefail
# Committed, no secrets:
cat > .env <<'EOF'
NODE_ENV=development
LOG_LEVEL=info
PORT=3000
EOF
cat > .env.development <<'EOF'
DATABASE_URL=postgresql://app:app@localhost:5432/app_dev
EOF
cat > .env.production <<'EOF'
LOG_LEVEL=warn
EOF
# Ignored, holds the actual secret for this machine/mode only:
cat > .env.development.local <<'EOF'
SESSION_SECRET=dev-only-local-secret
EOF
echo "Wrote layered env files"
- Lock the ignore policy so no
*.localfile — the only tier that holds secrets — can ever be committed. Order matters: the negations must precede the broad globs.
# .gitignore
# Commit the shared and per-mode non-secret files
!.env
!.env.development
!.env.production
!.env.test
# Never commit anything holding a real secret
.env.local
.env.*.local
Verify the policy with git check-ignore -v .env.development.local .env.development: the first path must report a match, the second must print nothing. Run that once after editing the ignore file and a mis-ordered glob can never silently track a secret.
- Load exactly the four files for the active mode, in priority order, with the deterministic
scripts/load-env.mjsfrom the diagnostic. Apply it to a shell by evaluating its output:
#!/usr/bin/env bash
set -euo pipefail
export NODE_ENV="${NODE_ENV:-development}"
eval "$(node scripts/load-env.mjs)"
echo "Booting in ${NODE_ENV} with DATABASE_URL=${DATABASE_URL}"
- Layer the same files into Docker Compose so the container resolves them identically. Compose's long
env_filesyntax lets the mode-specific overlays be optional, so a missing*.localfile on a fresh checkout does not error:
# docker-compose.yml
services:
app:
build: .
env_file:
- path: .env
required: true
- path: .env.${NODE_ENV:-development}
required: false
- path: .env.${NODE_ENV:-development}.local
required: false
ports:
- "3000:3000"
- Confirm the resolution moved to the tier you intended before trusting the boot. The provenance report is the authoritative check:
#!/usr/bin/env bash
set -euo pipefail
NODE_ENV=development node scripts/load-env.mjs --provenance
Expected output
With the secret confined to .env.development.local and the loader scoped to the active mode, the development boot never touches production. The provenance report shows each key beside its source file, and no production-only value appears:
$ NODE_ENV=development node scripts/load-env.mjs --provenance
DATABASE_URL <- .env.development
LOG_LEVEL <- .env
NODE_ENV <- .env
PORT <- .env
SESSION_SECRET <- .env.development.local
DATABASE_URL resolves from .env.development — the local Postgres, not db.prod.internal — and SESSION_SECRET comes only from the ignored mode-local file. Switching to production loads a disjoint secret set and never reads .env.local:
$ NODE_ENV=production node scripts/load-env.mjs --provenance
LOG_LEVEL <- .env.production
NODE_ENV <- .env
PORT <- .env
Because .env.development and .env.production are separate committed files and secrets live only in their respective *.local overlays, a boot in one mode is structurally incapable of surfacing another mode's secret.
Prevention
- Block any secret from entering a committed file. A regex pre-commit hook rejecting high-entropy values in the tracked env files catches the most common mistake — pasting a real credential into
.env.productioninstead of.env.production.local. For a purpose-built scanner, wire in a gitleaks pre-commit hook that blocks committed secrets.
#!/usr/bin/env bash
set -euo pipefail
# Reject secret-looking values in COMMITTED env files (never the *.local ones).
tracked=$(git ls-files '.env' '.env.*' ':!:.env.local' ':!:.env.*.local')
if grep -nEH '(SECRET|TOKEN|PASSWORD|_KEY)=.{12,}' $tracked 2>/dev/null; then
echo "ERROR: a secret-looking value is in a committed env file"; exit 1
fi
echo "No secrets in committed env files"
- Prove the
*.localfiles are ignored, so a straygit add -for a broken.gitignorecannot ship one. Fail CI if any*.localfile is tracked:
#!/usr/bin/env bash
set -euo pipefail
leaked=$(git ls-files '.env.local' '.env.*.local')
if [ -n "$leaked" ]; then
echo "ERROR: secret-bearing files are tracked:"; echo "$leaked"; exit 1
fi
echo "No *.local secret files are tracked"
- Assert no cross-mode leakage directly: the key set a development boot sees must not contain any key that only production defines. This is the check that would have caught the original bug. Pair it with the presence checks in catching missing env vars before container startup so absence and over-exposure are gated together.
#!/usr/bin/env bash
set -euo pipefail
dev=$(NODE_ENV=development node scripts/load-env.mjs --keys)
prod=$(NODE_ENV=production node scripts/load-env.mjs --keys)
# Keys unique to production must never appear in a development boot.
leaked=$(comm -13 <(echo "$dev" | sort) <(echo "$prod" | sort) \
| grep -Fxf <(echo "$dev") || true)
if [ -n "$leaked" ]; then
echo "ERROR: production-only keys leaked into development:"; echo "$leaked"; exit 1
fi
echo "No cross-mode key leakage"
*.local files may hold a real secret.Platform caveats
WSL2: Windows
CRLFline endings breakdotenvparsing — a trailing\rbecomes part of the value, soNODE_ENV=production\rnever matches the stringproductionand the loader silently falls back to the default mode. Setgit config --global core.autocrlf inputand rundos2unix .env .env.*before debugging further.
macOS (Docker Desktop): the shell that launches Compose may inherit a stray
NODE_ENVfrom a login profile or a previous session, and${NODE_ENV}inenv_filepaths then selects the wrong overlay. PassNODE_ENVexplicitly on the command line or unset it beforedocker compose upso the mode is never inherited by accident.
Apple Silicon (ARM64): a
.envcopied from an x86 CI runner can carry a leading UTF-8 BOM that makes the first key fail to parse, dropping it back to a lower tier and appearing to "leak" a default. Strip it withsed -i '' '1s/^\xEF\xBB\xBF//' .envon macOS orsed -i '1s/^\xEF\xBB\xBF//' .envon Linux.
Rollback
If a loader or ignore-policy change let a secret into a committed file, restore the tracked env files and the ignore rules from the last known-good commit, then rotate the exposed credential — restoring the file does not un-expose a value that already reached the remote:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- .gitignore .env .env.development .env.production scripts/load-env.mjs
git rm --cached --ignore-unmatch .env.local '.env.*.local'
echo "Restored ignore policy and loader; now ROTATE any secret that was committed"
The git rm --cached line untracks any *.local file that slipped in without deleting your local copy, so the next commit stops publishing it. Treat every secret that ever reached the remote as compromised and rotate it out of band; to purge it from history entirely, follow the removal workflow in managing local secrets without committing to Git.
Frequently Asked Questions
What is the exact load order for .env, .env.local, and .env.<mode>?
Highest priority to lowest: .env.<mode>.local, then .env.local, then .env.<mode>, then .env. The loader reads them lowest-first and lets later files overwrite earlier keys, so the highest-priority file that declares a key supplies its value. In test mode, .env.local is deliberately skipped so a developer's machine-local overrides never change how tests resolve, keeping local test runs identical to CI.
Why did my production database URL appear during a development run?
Almost always a loader that globs .env.* and sources every match regardless of mode. Alphabetical ordering then lets .env.production win over .env.development. Replace the glob with a loader that builds an explicit four-file list for the active mode, and run it in --provenance mode to see the source file behind each key. A value arriving from .env.production during a development boot is then immediately visible instead of silent.
Which .env files should I commit and which must stay ignored?
Commit the secret-free files: the shared .env and each .env.<mode> holding only non-sensitive, mode-specific defaults. Ignore every *.local file — .env.local and .env.<mode>.local — because those are the only tier allowed to hold a real credential. Put the negations (!.env, !.env.development) before the broad .env.*.local glob in .gitignore, and verify with git check-ignore -v that the local files match while the committed ones do not.
How do I stop a secret from ever landing in a committed env file?
Add a pre-commit hook that greps the tracked env files (excluding the *.local paths) for secret-looking assignments such as SECRET=, TOKEN=, or PASSWORD= with a long value, and reject the commit if any match. Back it with a dedicated scanner like gitleaks for entropy-based detection, and add a CI check that fails if any *.local file is tracked at all. Together they make putting a secret in the wrong tier a build failure rather than a silent leak.