Keeping .env.example Files Current
A new hire runs cp .env.example .env, starts the stack, and the API crashes with Error: Missing required environment variable PAYMENTS_WEBHOOK_SECRET — a variable added three months ago that never made it into the example file. Meanwhile .env.example still lists LEGACY_SEARCH_URL, removed last year, so people waste time looking for a value nobody needs. The example file is the contract for local configuration, and it rots faster than any other part of onboarding because adding a variable to code does not force anyone to touch it. This page keeps it current automatically, as part of dotenv configuration management.
The approach works whether the source of truth is the code itself or a configuration schema; the key is that the example file is checked against it in CI.
Diagnostic
Compare the variables the code reads with the keys in .env.example:
#!/usr/bin/env bash
set -euo pipefail
grep -rhoE 'process\.env\.[A-Z][A-Z0-9_]+' src | sed 's/process\.env\.//' | sort -u > /tmp/used.txt
grep -rhoE "os\.environ(\.get)?\(?\[?[\"'][A-Z][A-Z0-9_]+" api 2>/dev/null | grep -oE '[A-Z][A-Z0-9_]+' | sort -u >> /tmp/used.txt || true
sort -u -o /tmp/used.txt /tmp/used.txt
grep -oE '^[A-Z][A-Z0-9_]*' .env.example | sort -u > /tmp/declared.txt
echo "used in code, missing from .env.example:"; comm -23 /tmp/used.txt /tmp/declared.txt
echo "declared, no longer used:"; comm -13 /tmp/used.txt /tmp/declared.txt
Expected bad output:
used in code, missing from .env.example:
FEATURE_FLAGS_URL
PAYMENTS_WEBHOOK_SECRET
declared, no longer used:
LEGACY_SEARCH_URL
REDIS_SENTINEL_HOSTS
Two variables the code requires are missing from the example, and two dead keys confuse new hires.
Root cause
.env.example is a hand-maintained copy of information that lives elsewhere — in the code that reads variables, or in a configuration schema. Nothing links the two. A developer adding process.env.PAYMENTS_WEBHOOK_SECRET has a working local .env already and never re-reads the example file; reviewers look at code, not at a file the change did not touch. Removals are even less likely to propagate, because a stale key causes no error for existing developers. Over time the example describes the application as it was, and only new hires — who copy it verbatim — pay for the gap. The fix is to derive the key list from the source of truth, or to check the file against it on every pull request, so a variable cannot be added or removed without the example changing in the same diff.
Missing variables fail in two different ways, and the quieter one is worse. A required variable without a default crashes at startup, which at least points straight at the problem. A variable read with a fallback — process.env.FEATURE_FLAGS_URL ?? 'https://flags.acme.dev' — keeps working with a default that may be a production endpoint, a shared staging service, or simply wrong for local development. New hires then run against the wrong backend without any error, and discover it only when behaviour differs from what colleagues see. Listing every variable in the example, with its local value, makes those fallbacks visible and replaceable.
Resolution
- Declare configuration in one schema that the application validates at startup, with a description and a safe local default for each key — for example with zod, as covered in validating config at startup with zod and pydantic:
import { z } from 'zod';
export const configSchema = z.object({
DATABASE_URL: z.string().url().describe('Postgres connection string').default('postgres://postgres:postgres@localhost:5432/shop'),
PAYMENTS_WEBHOOK_SECRET: z.string().min(16).describe('Stripe webhook signing secret; run `stripe listen --print-secret`'),
FEATURE_FLAGS_URL: z.string().url().describe('Feature flag service').default('http://localhost:4242'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).describe('Log verbosity').default('info'),
});
- Generate
.env.examplefrom the schema so descriptions and defaults stay in one place:
import { writeFileSync } from 'node:fs';
import { configSchema } from '../src/config/schema';
const lines = ['# Generated by scripts/gen-env-example.ts — do not edit by hand', ''];
for (const [key, field] of Object.entries(configSchema.shape)) {
const def = field._def;
const description = field.description ?? '';
const fallback = def.typeName === 'ZodDefault' ? String(def.defaultValue()) : '';
lines.push(`# ${description}${fallback ? '' : ' (required)'}`, `${key}=${fallback}`, '');
}
writeFileSync('.env.example', lines.join('\n'));
#!/usr/bin/env bash
set -euo pipefail
npx tsx scripts/gen-env-example.ts
git diff --stat .env.example
- Fail CI when the example is out of date — either by regenerating and diffing, or, without a schema, by running the diagnostic comparison:
#!/usr/bin/env bash
set -euo pipefail
npx tsx scripts/gen-env-example.ts
git diff --exit-code .env.example && echo ".env.example is current" || { echo "regenerate .env.example: npx tsx scripts/gen-env-example.ts"; exit 1; }
- Tell existing developers about new keys at the moment they need them: the doctor script compares their
.envwith the example and prints missing keys with descriptions:
#!/usr/bin/env bash
set -euo pipefail
missing=$(comm -23 <(grep -oE '^[A-Z][A-Z0-9_]*' .env.example | sort -u) <(grep -oE '^[A-Z][A-Z0-9_]*' .env | sort -u))
for key in $missing; do
desc=$(grep -B1 "^$key=" .env.example | head -1 | sed 's/^# //')
echo "missing in .env: $key — $desc"
done
[ -z "$missing" ] && echo ".env has every key in .env.example"
Expected output
$ npx tsx scripts/gen-env-example.ts && git diff --exit-code .env.example && echo ".env.example is current"
.env.example is current
$ head -8 .env.example
# Generated by scripts/gen-env-example.ts — do not edit by hand
# Postgres connection string
DATABASE_URL=postgres://postgres:postgres@localhost:5432/shop
# Stripe webhook signing secret; run `stripe listen --print-secret` (required)
PAYMENTS_WEBHOOK_SECRET=
Every key the application reads is in the example, with a description and a safe default where one exists; required secrets are clearly marked, and dead keys are gone.
For existing developers the doctor output closes the other half of the loop: after pulling a change that adds a variable, make doctor names the new key and explains where to get its value, instead of the application failing at startup with a bare "missing variable" error. New hires and long-standing team members now receive the same information from the same source.
Prevention
Keep the generation check in required CI so a pull request that adds a variable without regenerating the example cannot merge.
Never put real secrets in the example file. Required secrets have empty values and a description of where to get them; see managing local secrets without committing to git.
Run the doctor comparison after every pull, for example from a
post-mergegit hook, so developers learn about new keys immediately.
Platform caveats
Monorepos: each service needs its own example file generated from its own schema; a single shared file mixes keys and makes required ones unclear.
Python services: generate from a pydantic
BaseSettingsmodel by iteratingmodel_fieldsand reading each field'sdescriptionanddefault.
Windows: generated files should use LF line endings; add
.env* text eol=lfto.gitattributesso Windows editors do not introduce CRLF, which some dotenv parsers keep as part of the value.
Rollback
Delete the generator and CI check; the example file becomes hand-maintained again:
#!/usr/bin/env bash
set -euo pipefail
git rm -q scripts/gen-env-example.ts
git checkout HEAD~1 -- .github/workflows/config.yml
Frequently Asked Questions
Why does the app crash for new hires but not for existing developers?
Existing developers added the new variable to their own .env when they needed it; new hires copy .env.example, which never got the key. Checking the example against the code in CI prevents that gap.
Should .env.example contain real default values?
Safe local defaults, yes — connection strings to local services, log levels. Never real secrets; leave those empty with a description of how to obtain them.
We have no configuration schema. Can we still check the file?
Yes. Grep the code for environment reads, as in the diagnostic, and compare with the example's keys in CI. A schema is better because it also carries descriptions and defaults.
How do existing developers learn about new variables?
Have the doctor script compare their .env with .env.example and print missing keys with descriptions, ideally run automatically after pulls.