How to Validate a .env File: 5 Methods That Catch Missing Keys Before Deploy
A missing environment variable can pass review, tests, and a green pipeline. Here are five ways to validate a .env file — and which ones actually run before deploy.
The pipeline went green. The key was already gone.
The deploy job finished. Tests passed. Then the new task came up without STRIPE_SECRET_KEY, and the first checkout failed in production. Nobody had deleted the key on purpose. It had never been on the ring you actually shipped.
The usual mistake is treating a name-check — or a crash at process start — as if it had run before deploy. Those are different clocks.
You can compare two packing lists, inspect the suitcase, try the lock at the door, or stop the bag at the gate. Most teams only do the last one after the plane has left.
Table of Contents
- Why a missing key survives review
- Method 1: Diff keys against .env.example
- Method 2: Dedicated env-file compare (dotenv-linter diff)
- Method 3: Fail-fast schema at process start
- Method 4: A schema CLI that does not start the app
- Method 5: Make the check a deploy gate
- Which method for which team
- What this piece will not do
- Frequently Asked Questions
Why a missing key survives review
Code review looks at logic. Unit tests mock the database URL. That is why catching environment variable errors early is a timing problem, not a code-review problem.
Twelve-factor tells you where config lives: in the environment, not in the repo. It does not tell you to validate it.
Loading a .env file does not close that gap. dotenv reads the file and copies names into process.env. That is a loader, not a required-key check.
Presence is not a missing-key check
People use one word for three different states.
Absent means the name is not there. In Node, that read is undefined until something sets it. Deleting a property is how you put it back in that state.
Empty means the name is there and the value is an empty string. A line like STRIPE_SECRET_KEY= in a file is empty, not absent. A key-diff against .env.example will treat it as present.
Useless means the name is there and the value is a placeholder (sk_test_your_key_here) or the literal string undefined — which is what you get if application code assigns undefined onto the environment instead of deleting the key.
A falsy check (if (!process.env.FOO)) piles those together. It cannot tell a forgotten production secret from an empty local override from the string undefined.
If you only ask “is the name on the list?” you will ship empty and placeholder values with a clean conscience. Presence is not a missing-key check.
Method 1: Diff keys against .env.example
Most teams already keep an example file. The method is: extract names from .env and from .env.example, then compare the sets.
# names only, ignore values and comments cut -d= -f1 .env | grep -v '^#' | grep -v '^$' | sort > /tmp/env.keys cut -d= -f1 .env.example | grep -v '^#' | grep -v '^$' | sort > /tmp/example.keys comm -23 /tmp/example.keys /tmp/env.keys
Anything printed is a name the example promised and the local file does not have. Zero dependencies.
What this catches — and what a copied placeholder hides
It catches a name that exists in the example and not in the file you compared. That is it.
It will not catch this:
# .env.example (committed) STRIPE_SECRET_KEY=sk_test_your_key_here DATABASE_URL=postgresql://user:password@localhost:5432/mydb # .env (what you are about to deploy from, or think you validated) STRIPE_SECRET_KEY= DATABASE_URL=postgresql://user:password@localhost:5432/mydb
Every name matches, and the Stripe key is empty. The database URL is still the example host. The packing lists agree. The suitcase is wrong. Same failure as the copy-paste trap — the name is there, the value is not.
It also cannot invent a name nobody wrote down. If last week’s pull request started reading SENTRY_DSN and nobody added that name to .env.example, the diff says the local file is complete.
The example is a memory of what someone last remembered to list. It is not a contract.
And if production never reads this .env file — if the host, the cluster, or the CI secret store injects DATABASE_URL — you validated the wrong suitcase.
Method 2: Dedicated env-file compare (dotenv-linter diff)
Same idea as method 1, with a tool that already knows .env syntax. The Rust dotenv-linter (not the Python project of the same name) has three jobs: check, fix, and diff. Diff is the missing-key one.
dotenv-linter diff .env .env.example
The README example reports both directions: .env is missing keys: BAR and .env.example is missing keys: FOO.
That second line is useful. It tells you the example drifted the other way — a name landed in a local file and never made it back to the list new developers copy.
Why check and diff are not the same job
dotenv-linter check looks for format problems: duplicate keys, unordered keys, a bad leading character. It does not ask whether .env has every name in .env.example.
The same trap shows up under a different product name. env-sentinel’s lint rule no-missing-key means this line has no key name — a malformed =value or a broken line. The documented example is Variable name is missing. That is not “STRIPE_SECRET_KEY is absent from the file.”
A linter that says “missing key” may not mean a required variable is absent. Method 2 is still presence-only: empty Stripe keys and copied placeholders pass.
Method 3: Fail-fast schema at process start
The contract moves into code. You declare the names the process needs, and you refuse to boot without them.
Envalid’s cleanEnv logs an error and exits in Node (or throws in a browser) if a required variable is missing or invalid.
import { cleanEnv, str, url } from 'envalid' export const env = cleanEnv(process.env, { STRIPE_SECRET_KEY: str(), DATABASE_URL: url(), })
Zod does the same job if you parse process.env as an object: properties are required by default, and parse({}) fails when a required name is absent.
import { z } from 'zod' const env = z.object({ STRIPE_SECRET_KEY: z.string().min(1), DATABASE_URL: z.string().url(), }).parse(process.env)
z.string() alone accepts an empty string. nonempty is an alias for min(1). If you wanted empty to fail, you have to say so. Envalid is explicit about the same hole: an empty string is a valid str() value unless you write a custom validator.
t3-env’s core docs make the empty-string problem operational. They recommend emptyStringAsUndefined because a line like PORT= in a .env file is an empty string, and that empty string will fail a number check or block a default. Their own secret examples use z.string().min(1), not plain z.string().
Boot-time validation is not before-deploy
cleanEnv runs when you call it. That is usually the first import of the server process — after the deploy job already went green, inside the task that is now crash-looping.
A schema in application code is a good lock. It is a late lock if the only time you turn it is at the door.
The same schema can run earlier. t3-env’s docs recommend validating on build by importing the env module from the config file or any file the build pulls in first. That is still “when this module loads,” not magic. The difference is which process loads it: the build job in CI, or the production server at 2 a.m.
Envalid can also express “required only in production” with devDefault or requiredWhen. That is the right tool for a key the platform injects in production (DATABASE_URL on a host that never sees your committed .env) and that you fake locally. It is the wrong tool if you never invoke the module until the cluster starts the task.
Method 4: A schema CLI that does not start the app
A file-shaped contract, checked by a command, no server boot.
env-sentinel validates a .env file against a schema for type safety, presence of required variables, and formatting. You declare the names and the rules; npx env-sentinel validate compares the file to that schema.
# .env-sentinel (schema) STRIPE_SECRET_KEY=required|min:32 DATABASE_URL=required|string APP_ENV=required|enum:development,staging,production APP_PORT=number|min:1|max:65535
required with a minimum length is the check method 1 and method 2 cannot do: the name must exist and the value must be long enough to be a real key, not an empty line and not sk_test_your_key_here if that placeholder is shorter than the minimum.
env-sentinel init will generate a schema from a current file if you do not want to start from a blank page. The generated file is a draft of the contract. You still decide what is required.
This is the method that fails the empty Stripe key. That value is present for a key-diff and valid for str() / z.string(). It is not valid for required|min:32.
Keep the schema honest. If you add a name in code and not in the schema, validate will not invent it — same failure mode as a stale .env.example, except the schema is the thing you meant to be the contract.
A CI job that only runs env-sentinel lint is not this method. Lint will catch a line with no key name, empty values, duplicates, unsafe characters. The linting docs’ own CI snippet runs lint, not validate. Format is not a required-key contract.
Method 5: Make the check a deploy gate
Methods 1–4 are checks. Method 5 is the timing that makes them “before deploy.”
Put the check in the pipeline as a required step, before the deploy job, and fail closed. A nonzero exit is how GitHub marks a check failed and skips later work. A CLI that exits nonzero when a required key is absent is enough. You do not need a custom action — you need a step that is not optional.
jobs: check-env: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Validate required keys run: npx env-sentinel validate --file .env.ci deploy: needs: check-env # …
Whether this actually protects production comes down to two details.
Validate the environment the deploy will receive, not a developer laptop file that never ships. If CI injects secrets, point the tool at that env (a generated file, the job’s env, the image’s runtime). Checking .env in the repo while production reads the cluster is the wrong suitcase again.
And do not substitute lint for validate. A green lint job means the file is well-formed. It does not mean STRIPE_SECRET_KEY exists and is long enough.
If your contract already lives in envalid or t3-env, the gate can be “import that module in CI” — node -e "import './src/env.ts'" or the build-time import t3-env recommends — instead of a second schema. One contract, two timings: the build job, then the process. The process is backup. The job is the gate.
Which method for which team
Presence-only (methods 1 and 2) is enough when the only failure you have is “someone forgot to copy a name into the new file,” and you accept that empty and placeholder values will pass. Use method 2 if you want a maintained tool and two-way drift. Use method 1 if you will not add a binary.
A real contract (methods 3 and 4) is what you need when empty, short, or out-of-enum values have already burned you. Method 3 if the app is Node and the schema should type the rest of the codebase. Method 4 if you want the same check without booting the app, or you have more than one language reading the same file.
Skip the gate and “before deploy” is a slogan. Method 3 without it is a crash in the cluster. Method 3 or 4 with it is a red job.
Do not run all five as five sources of truth. Pick one contract — the example file, or the code schema, or the schema file — and one gate that runs that contract against the env you will actually ship. A second presence-diff is fine as a cheap extra. A second schema that can drift from the first is how you get a green check and a missing key.
For the broader practice around naming, secrets, and team process, see environment variable management.
What this piece will not do
This is not a guide to storing secrets, rotating vaults, or keeping .env out of git. It skips Next.js NEXT_PUBLIC_* bundling and Rails credentials. env-sentinel’s own lint and docs guides stay where they are.
The Stripe key that never made it onto the ring was not a secrets problem. It was a clock problem. The example file is a memory. The schema — in code or in a file — is the contract. The pipeline is the only clock that makes either of them “before deploy.”
Frequently Asked Questions
How do I validate a .env file?
Pick one contract and run it against the file you will actually ship. A key-diff against .env.example only checks names. A schema — in code (envalid, Zod, t3-env) or in a file (npx env-sentinel validate) — can reject empty and placeholder values. Put that check in CI before deploy or it is not “before deploy.”
How do I validate environment variables in CI?
Add a required job that exits nonzero when the contract fails, and make deploy needs that job. Point the tool at the environment the deploy will receive, not a laptop .env that never ships. npx env-sentinel validate --file .env.ci is one way. Importing an envalid or t3-env module during the build is another. Do not substitute lint for validate.
Does a key-diff against .env.example catch empty values?
No. STRIPE_SECRET_KEY= has the name. The lists match. The value is empty. The same is true of copied placeholders like sk_test_your_key_here. Presence is not a missing-key check.
Is boot-time validation the same as validating before deploy?
No. cleanEnv or z.object(...).parse(process.env) runs when that module loads — usually after the deploy job already went green. To make the same schema a gate, import it in the build job or run a CLI against the env CI will ship.
What is the difference between env-sentinel lint and validate?
Lint checks the file is well-formed: missing key names on a line, empty values, duplicates, unsafe characters. Validate checks the file against a schema: required names, types, min/max, enums. A green lint job is not a required-key contract.
Related Articles
Continue reading with these related articles.
How to Catch Environment Variable Errors Early
Environment variable issues such as typos, missing keys, and invalid values can cause costly bugs. Discover strategies and tools to detect and prevent these errors during development and CI/CD.
Read articleCommon mistakes teams make with .env files — and how to avoid them
Environment files seem simple until they're not. A single typo can bring down production. Discover the most common mistakes teams make with .env files and practical solutions to avoid deployment failures and debugging nightmares.
Read articleDebugging Production Incidents Caused by 'Invisible' Configuration Changes
Production breaks. Logs show nothing. Code hasn't changed. The culprit? Invisible configuration changes—environment variables, secrets, system settings that drift without anyone noticing. Here's how to debug them when everything's on fire.
Read article