How to find missing environment variables
“It works on my machine” is almost always an environment variable. Someone set one months ago, never wrote it down, and the project has depended on it quietly ever since. Finding the variable is rarely the hard part. Working out which name you are looking for is.
Two different failure modes
Environment variables fail in two ways, and telling them apart saves most of the time you would otherwise spend guessing.
The loud kind stops the app starting. The framework bootstraps, hits a value it cannot do without, and dies. The message is almost never helpful, because the code that fails is several layers below the code that cared: a base64 decode error, a null dereference deep inside a bootstrap file, “could not connect to database” when the database is fine and the password is an empty string.
The quiet kind does nothing at all. The feature is switched off, no error is raised, nothing appears in the log. Some ways that shows up:
- A feature that silently no-ops. The email did not send, the upload did not happen, the webhook was never signed, and every request returned 200.
- A 500 that only happens in staging. Local is fine, production is fine, and the environment nobody looks at is missing one value.
- A queue worker that dies on one job type and processes everything else happily, because only that job touches the service whose credentials are absent.
The loud kind you find by reading a stack trace carefully. The quiet kind you find by knowing what the project is supposed to have, which is the harder problem and the rest of this page.
Diffing against .env.example
The obvious first move is to compare your env file against the example the repo ships. It works, right up until it doesn’t: .env.example is only accurate if somebody updated it in the same commit as the code that reads the new variable, and that is exactly the step people skip. A stale example file is worse than none, because it gives you a confident answer that is wrong.
Still worth doing, and it takes one function. This is bash or zsh, so process substitution is available:
envkeys() {
sed -E 's/^[[:space:]]*(export[[:space:]]+)?//' "$1" \
| grep -E '^[A-Za-z_][A-Za-z0-9_]*=' \
| cut -d= -f1 \
| sort -u
}
# Keys the example has and your file does not
comm -13 <(envkeys .env) <(envkeys .env.example)
# Keys you have that the example never documented
comm -23 <(envkeys .env) <(envkeys .env.example)
Four things that pipeline gets right, all of which naive versions get wrong:
- Comments and blank lines are dropped, because the
greponly accepts a line that starts with a valid key name followed by=. - A commented-out variable like
#DEBUG=truecounts as absent, which is correct. The line is documentation, not a value. cut -d= -f1splits on the first=only, so a value containing more equals signs (DB_PASSWORD=p@ss=word) still yields one clean key.- A leading
exportis stripped, and so is leading whitespace.
The second command is the one people forget to run, and it is often the more interesting of the two. Keys you have and the example does not are the ones the next person to clone the repo will not know about.
Grepping the codebase
The other manual approach is to ask the code what it reads. Four idioms cover most of it: env( in PHP, process.env. in JavaScript, os.environ in Python, and ENV[ in Ruby.
grep -rhoE "env\((['\"])[A-Z0-9_]+" app config | grep -oE '[A-Z0-9_]+$' | sort -u
grep -rhoE "process\.env\.[A-Za-z0-9_]+" src | sed 's/.*\.//' | sort -u
grep -rhoE "os\.environ(\.get)?[\(\[](['\"])[A-Z0-9_]+" . | grep -oE '[A-Z0-9_]+$' | sort -u
grep -rhoE "ENV(\.fetch)?[\(\[](['\"])[A-Z0-9_]+" app config | grep -oE '[A-Z0-9_]+$' | sort -u
Useful, and untrustworthy in both directions.
It under-reports. A dynamic lookup such as process.env[name] has no literal to match, so it never appears. Frameworks with a config layer read env values in one place and everything else reads the config, so grepping application code finds config('services.stripe.key') and never the variable behind it. Vendored dependencies read their own variables from inside vendor/ or node_modules/, which you almost certainly excluded from the search, and those are precisely the variables nobody documented.
It over-reports too. process.env.PORT ?? 3000 shows up as a variable the project reads, but its absence is not a problem. Anything with a documented default or an alias will pad your list with things that were never missing.
Not every absent variable is missing
This is the distinction worth internalising, because it is what separates a useful warning from a noisy one.
A variable whose absence stops the app booting is missing. A Laravel APP_KEY. A WordPress DB_NAME, DB_USER, DB_PASSWORD or DB_HOST. A DATABASE_URL where Prisma is the data layer. A Django or Flask SECRET_KEY, a Rails SECRET_KEY_BASE, a Symfony APP_SECRET. There is no default, nothing to fall back on, and the process will not come up.
A variable whose absence leaves a third-party integration switched off is not missing, however important it is to a project that uses it. SENTRY_DSN, STRIPE_SECRET_KEY, AWS_ACCESS_KEY_ID, FLARE_KEY. The app boots, serves traffic, and one optional thing stays off.
Flare is the case worth spelling out, because it shows why the distinction has to be enforced rather than eyeballed. Flare is the paid error-tracking service that sits on top of a package which ships in Laravel’s default skeleton. Detect the package, treat its variable as required, and every Laravel project in existence gets told it is broken. It is not broken. It just does not have a Flare account, and it never needed one.
The test that sorts them is: can this warning only be cleared by signing up for something? If yes, it is an integration and it should never be flagged as missing. Nobody wants a permanent warning they cannot clear.
There is a third bucket that deserves no warning at all. NODE_ENV, APP_NAME, REDIS_URL, AWS_REGION and their kind have documented defaults or aliases, so their absence is unremarkable and mentioning it is just noise.
Stopping it happening again
Three habits, in descending order of how much they help.
Update .env.example in the same commit as the code that reads the new variable. Not the same afternoon, not the same PR review round. The same commit. This is the whole ballgame, and it is also the thing that decays first.
Fail fast at boot with a message that names the variable. If a value is genuinely required, read it during startup and throw with the variable name in the error text. A named failure at boot beats a silent no-op in production by a wide margin, and it turns the quiet failure mode into the loud one, which is the one you can actually debug.
Validate the whole set at startup rather than at first use. In Laravel, keep env() calls inside config files as the framework intends, then assert on the resulting config early in the boot sequence so a missing value surfaces at start rather than the first time that code path runs. In JavaScript and TypeScript projects the equivalent is parsing the environment through a schema at startup, so the process either comes up with everything it needs or does not come up at all. Either way the failure arrives once, at a predictable moment, with a name attached.
Where Dotvault helps
Dotvault reads the manifests in a project (package.json, composer.json, Gemfile, requirements.txt and pyproject.toml) to work out which frameworks and packages are in play, and it knows which variables those need. Over 110 are recognised. That is how it can tell you what a project expects without you maintaining a list.
Only the boot-blocking class is flagged as missing. A banner at the top of the editor lists the variables the app will not start without, each chip naming the framework or package that expects it, and clicking a chip adds the key to the top of the file. Variables for optional services are handled differently: Dotvault still recognises them, still labels each row with the source and a short description, and still offers them as suggestions, but it will never tell you they are missing. The Required filter in the editor uses the same rule, so filtering to Required gives you the boot blockers and nothing else.
For the drift problem, two things. After you save an env file, Dotvault checks whether any of your keys are absent from .env.example and offers to add them, keys-only or keys with values, so the example file stops falling behind by default rather than by discipline. And when the question is “staging has it and production does not”, the Compare tab puts every variable across every env file in the project into one matrix and highlights the rows where values differ, which is the fastest way to see a gap you were not looking for.
Related reading: why a variable is flagged as missing, what the Compare tab does, keeping env files in sync, how frameworks and packages are detected, and the full list of recognised packages.
Dotvault is a Mac app for managing .env files, with a 14-day trial and no account required. Have a look.