How to check config drift between environments
Diff three key sets, not two: .env.example, .env, and the names the code reads. node env-drift.mjs prints each side of the difference and exits 1. The run below finds a key missing from the example, a key read by nothing, and a key the code reads that no file provides.
Why check this
Run this when a test environment is built, when a service picks up a new dependency, and before any release that adds configuration. Drift accumulates quietly: nobody edits .env.example while debugging, and nobody deletes a key when the feature that used it is removed.
Each pair has its own failure. A key in the example and not in the environment stops the service at startup, the visible case. A key read by the code and in neither file reaches production as an undefined that some branch treats as a default. A key in both files and read by nothing gets copied into the next environment for years, and in a .env it is usually a credential nobody rotates.
Prerequisites
- Node 22.23.2. Every output on this page came from one Windows 11 machine on 2026-09-12.
grep, from Git Bash on Windows or from any Unix shell.- A dotenv build for step 3. This page used @next/env 16.3.5, which every Next.js project has. Expansion of one key inside another is a property of the loader, so check the one your service runs.
- A fixture with the drift already in it. Six source files, an example with 8 keys, an environment with 7.
mkdir -p drift/app && cd drift
printf '%s\n' 'DB_URL=postgres://localhost:5432/app' 'API_TOKEN=' 'PORT=3000' \
'LOG_LEVEL=info' 'SMTP_HOST=localhost' 'SMTP_PORT=1025' \
'FEATURE_NEW_CHECKOUT=false' 'LEGACY_CACHE_URL=redis://localhost:6379' > .env.example
printf '%s\n' 'DB_URL=postgres://localhost:5432/app_test' 'API_TOKEN=fake-token-1' \
'PORT=9711' 'SMTP_HOST=localhost' 'SMTP_PORT=1025' \
'LEGACY_CACHE_URL=redis://localhost:6379' 'SESSION_SECRET=fake-secret-2' > .env
echo 'export const dbUrl = process.env.DB_URL;' > app/db.ts
echo 'export const port = Number(process.env.PORT);' > app/http.ts
echo 'export const token = process.env.API_TOKEN;' >> app/http.ts
echo 'const { SMTP_HOST, SMTP_PORT } = process.env;' > app/mail.ts
echo "export const newCheckout = process.env['FEATURE_NEW_CHECKOUT'] === 'true';" > app/flags.ts
echo 'export const secret = process.env.SESSION_SECRET;' > app/session.ts
echo 'export const bucket = process.env.REPORT_BUCKET;' > app/report.ts
Steps
- Step 1.
List the variables the code reads directly.
grep -rhoE 'process\.env\.[A-Z0-9_]+' app | sort -uprocess.env.API_TOKEN process.env.DB_URL process.env.PORT process.env.REPORT_BUCKET process.env.SESSION_SECRETFive names from six files that read eight variables.
app/mail.tsdestructures,app/flags.tsuses bracket access, and this pattern sees neither. - Step 2.
Diff the three sets: the example, the environment, and the names the code reads.
cat > env-drift.mjs <<'EOF' import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; const dotOnly = process.argv.includes('--dot-only'); const keysOf = (file) => new Set( readFileSync(file, 'utf8') .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l && !l.startsWith('#') && l.includes('=')) .map((l) => l.slice(0, l.indexOf('=')).replace(/^export\s+/, '').trim()) ); function sourceKeys(dir, found = new Set()) { for (const name of readdirSync(dir)) { const path = join(dir, name); if (statSync(path).isDirectory()) { sourceKeys(path, found); continue; } if (!/\.(ts|tsx|js|mjs|jsx)$/.test(name)) continue; const src = readFileSync(path, 'utf8'); for (const m of src.matchAll(/process\.env\.([A-Z0-9_]+)/g)) found.add(m[1]); if (dotOnly) continue; for (const m of src.matchAll(/process\.env\[['"]([A-Z0-9_]+)['"]\]/g)) found.add(m[1]); for (const m of src.matchAll(/(?:const|let|var)\s*\{([^}]+)\}\s*=\s*process\.env/g)) { for (const part of m[1].split(',')) { const key = part.split(':')[0].split('=')[0].trim(); if (/^[A-Z0-9_]+$/.test(key)) found.add(key); } } } return found; } const example = keysOf('.env.example'); const actual = keysOf('.env'); const code = sourceKeys('app'); const minus = (a, b) => [...a].filter((k) => !b.has(k)).sort(); const report = [ ['in .env.example, missing from .env', minus(example, actual)], ['in .env, missing from .env.example', minus(actual, example)], ['read by the code, in neither file', minus(code, new Set([...example, ...actual]))], ['in both files, read by no code', minus(new Set([...example].filter((k) => actual.has(k))), code)], ]; let drift = 0; for (const [label, keys] of report) { console.log(`${label}: ${keys.length ? keys.join(', ') : 'none'}`); drift += keys.length; } console.log(`.env.example ${example.size} keys, .env ${actual.size} keys, code reads ${code.size} keys`); process.exit(drift === 0 ? 0 : 1); EOFecho '=== node env-drift.mjs ==='; node env-drift.mjs; echo "exit $?" echo '=== node env-drift.mjs --dot-only ==='; node env-drift.mjs --dot-only; echo "exit $?"=== node env-drift.mjs === in .env.example, missing from .env: FEATURE_NEW_CHECKOUT, LOG_LEVEL in .env, missing from .env.example: SESSION_SECRET read by the code, in neither file: REPORT_BUCKET in both files, read by no code: LEGACY_CACHE_URL .env.example 8 keys, .env 7 keys, code reads 8 keys exit 1 === node env-drift.mjs --dot-only === in .env.example, missing from .env: FEATURE_NEW_CHECKOUT, LOG_LEVEL in .env, missing from .env.example: SESSION_SECRET read by the code, in neither file: REPORT_BUCKET in both files, read by no code: LEGACY_CACHE_URL, SMTP_HOST, SMTP_PORT .env.example 8 keys, .env 7 keys, code reads 5 keys exit 1--dot-onlyrestricts the scan to the pattern from step 1, and it finds 5 of the 8 keys the code reads. On that countSMTP_HOSTandSMTP_PORTlook unused, and removing them breaks mail on the next deploy. The full scan leaves one genuine leftover,LEGACY_CACHE_URL. Four lines, four different actions. - Step 3.
Compare values with the loader the application uses, because two loaders read one file differently.
mkdir loaders && printf '%s\n' '# database' 'PLAIN=abc' 'TRAILING=abc ' \ 'INLINE=abc # deployed port' 'QUOTED="abc def"' 'HASH=ab#cd' \ 'EXPAND=${PLAIN}/db' 'export EXPORTED=yes' > loaders/.envcd loaders echo '=== node --env-file, the parser built into Node ===' node --env-file=.env -e "for (const k of ['PLAIN','TRAILING','INLINE','QUOTED','HASH','EXPAND','EXPORTED']) console.log(k.padEnd(9), JSON.stringify(process.env[k]))" echo '=== dotenv, through @next/env ===' node -e "const {loadEnvConfig}=require('@next/env'); const {combinedEnv}=loadEnvConfig(process.cwd(),false,{info(){},error:console.error}); for (const k of ['PLAIN','TRAILING','INLINE','QUOTED','HASH','EXPAND','EXPORTED']) console.log(k.padEnd(9), JSON.stringify(combinedEnv[k]))"=== node --env-file, the parser built into Node === PLAIN "abc" TRAILING "abc" INLINE "abc" QUOTED "abc def" HASH "ab" EXPAND "${PLAIN}/db" EXPORTED "yes" === dotenv, through @next/env === PLAIN "abc" TRAILING "abc" INLINE "abc" QUOTED "abc def" HASH "ab" EXPAND "abc/db" EXPORTED "yes"Six of the seven values agree.
EXPANDdoes not: Node's own parser stores the literal text, the dotenv parser behind@next/envexpands it toabc/db. Both cutHASHat the unquoted#, so a generated password containing a hash character arrives truncated and the failure presents as a wrong credential. - Step 4.
Check what a trailing space and a CRLF line ending do to a value.
mkdir ws && printf '%s\r\n' 'TRAIL_UNQUOTED=abc ' 'TRAIL_QUOTED="abc "' > ws/.envcd ws echo '=== node --env-file ===' node --env-file=.env -e "for (const k of ['TRAIL_UNQUOTED','TRAIL_QUOTED']) console.log(k.padEnd(15), JSON.stringify(process.env[k]))" echo '=== dotenv, through @next/env ===' node -e "const {loadEnvConfig}=require('@next/env'); const {combinedEnv}=loadEnvConfig(process.cwd(),false,{info(){},error:console.error}); for (const k of ['TRAIL_UNQUOTED','TRAIL_QUOTED']) console.log(k.padEnd(15), JSON.stringify(combinedEnv[k]))" echo '=== split on the newline, no trim ===' node -e "const t=require('fs').readFileSync('.env','utf8'); const m=new Map(); for (const l of t.split('\n')) { const i=l.indexOf('='); if (i<0) continue; m.set(l.slice(0,i), l.slice(i+1)); } for (const k of ['TRAIL_UNQUOTED','TRAIL_QUOTED']) console.log(k.padEnd(15), JSON.stringify(m.get(k)))"=== node --env-file === TRAIL_UNQUOTED "abc" TRAIL_QUOTED "abc " === dotenv, through @next/env === TRAIL_UNQUOTED "abc" TRAIL_QUOTED "abc " === split on the newline, no trim === TRAIL_UNQUOTED "abc \r" TRAIL_QUOTED "\"abc \"\r"Both real loaders strip trailing spaces from an unquoted value, keep them inside quotes, and drop the carriage return. The three-line parser in the last block keeps it, so on a file with Windows line endings every value ends in a carriage return and every key reports as different.
- Step 5.
Check whether the file wins when the variable is already in the environment.
cd ../loaders echo '=== PLAIN exported as shell-wins, then node --env-file ===' PLAIN=shell-wins node --env-file=.env -e "console.log('PLAIN =', JSON.stringify(process.env.PLAIN))" echo '=== PLAIN exported as shell-wins, then dotenv through @next/env ===' PLAIN=shell-wins node -e "const {loadEnvConfig}=require('@next/env'); loadEnvConfig(process.cwd(),false,{info(){},error:console.error}); console.log('PLAIN =', JSON.stringify(process.env.PLAIN))"=== PLAIN exported as shell-wins, then node --env-file === PLAIN = "shell-wins" === PLAIN exported as shell-wins, then dotenv through @next/env === PLAIN = "shell-wins"Both loaders leave an existing value alone. A key can be correct in
.env, read by the code, and still never used, because the job exported an older value first. No comparison of key sets sees that, which is why this step exists.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| in .env.example, missing from .env | A new key was added and this environment did not follow | Set it here, or remove it from the example if the feature is gone |
| in .env, missing from .env.example | The example is out of date | Add the key with a placeholder value, never with the real one |
| read by the code, in neither file | The code depends on a name nothing provides | Add it to both files, or delete the read if the branch is dead |
| in both files, read by no code | A leftover. In a .env usually an unrotated credential | Confirm with a wider grep, then remove it from both files |
| A key reported unused that the service needs | The read form is not in your pattern | Add bracket access and destructuring to the pattern, as step 2 does |
Common mistakes
What to check next
- How to check if env variable exists: the startup guard that turns a missing key into a failed deploy.
- How to mock environment variables in vitest: the same keys inside a test run.
- How to check for secrets in environment variables: what to do about the leftover key the fourth line reports.
- How to check environment parity: the wider comparison, beyond the env files.
- Test environment checklist: the full pass over a test environment before sign-off.
FAQ
How do I compare two env files?
Compare key sets, not lines. diff on two .env files reports every changed value as a difference, which is expected and not drift. The script in step 2 sorts keys and prints each side of the difference, so a review reads four lines rather than a patch.
Should the diff compare values as well?
Only for keys that are meant to match across environments, such as a feature flag or a log level. Connection strings and secrets differ by design. A value comparison also needs the application's own loader, because step 3 shows three parsers reading one file three ways.
Where should this run?
As a step in CI on every pull request, with a non-zero exit. Drift is cheap to fix the day the key is added and expensive six months later, when nobody remembers whether LEGACY_CACHE_URL is still read.
What about secrets managers, where there is no .env file?
The three sets are the same. Replace the file reads with a listing of key names from the store. The pair that matters most, names the code reads against names the store holds, does not depend on where the values live.
Does .env.example need every key?
Every key the code reads. A key that only production sets belongs there too, with an empty value and a comment, because its absence from the example is what makes a new environment start without it.
Verified
Verified by Maks Vernynode 22.23.2@next/env 16.3.5
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate9 minpublished updated Maks Verny