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

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

  1. Step 1.

    List the variables the code reads directly.

    grep -rhoE 'process\.env\.[A-Z0-9_]+' app | sort -u
    
    process.env.API_TOKEN
    process.env.DB_URL
    process.env.PORT
    process.env.REPORT_BUCKET
    process.env.SESSION_SECRET

    Five names from six files that read eight variables. app/mail.ts destructures, app/flags.ts uses bracket access, and this pattern sees neither.

  2. 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);
    EOF
    
    echo '=== 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-only restricts the scan to the pattern from step 1, and it finds 5 of the 8 keys the code reads. On that count SMTP_HOST and SMTP_PORT look 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.

  3. 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/.env
    
    cd 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. EXPAND does not: Node's own parser stores the literal text, the dotenv parser behind @next/env expands it to abc/db. Both cut HASH at the unquoted #, so a generated password containing a hash character arrives truncated and the failure presents as a wrong credential.

  4. 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/.env
    
    cd 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.

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

Sign: A cleanup deletes a key the diff reported as unused and mail or payments break.Cause: grep for process.env.NAME misses const { SMTP_HOST } = process.env and process.env['NAME']. Both are common in configuration modules, so the false positive lands on the keys most likely to be grouped and destructured.
Sign: A value with a hash character in it arrives truncated.Cause: Node's --env-file and the dotenv parser both treat an unquoted # as the start of a comment, so HASH=ab#cd becomes ab. Quoting the value keeps it whole, and a generated password is where this shows up.
Sign: A diff script reports every key as different on one machine only.Cause: The file has CRLF line endings and the parser splits on the newline without trimming, leaving a carriage return at the end of every value. The two strings print identically and compare unequal.
Sign: The value in .env is right, the code reads the key, and the running service uses something else.Cause: Both loaders skip a key that is already present in the environment. A stale value exported earlier in the CI job wins over the file, and no comparison of key sets can see it.

What to check next

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.

intermediate9 minpublished updated Maks Verny