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.

Published: August 17, 202610 min read

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

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.

Continue reading with these related articles.

tracking pixel