# Keeping .env.example in sync with every environment

> A .env.example drifts because nothing breaks when it's wrong. Compare names, never values, fail CI on missing variables, and compare real environments.

URL: https://crunu.com/blog/keep-env-example-in-sync
Language: en
Updated: 2026-10-30

A `.env.example` falls out of sync because nothing breaks when it's wrong. The code reads the variables, the real environments hold the values, and the example file only documents them, so a forgotten line costs nothing until a new developer or a new server needs it. Keep it honest by comparing names, never values: check the names in `.env.example` against each real environment and against what the code reads, fail CI when a required name is missing, and compare development, staging and production with each other the same way. None of these checks needs to print a value.

What belongs in the file is covered in [should you commit .env files?](https://crunu.com/blog/should-you-commit-env-files). This post is about keeping it right after the first commit. For sharing the real files, see the guide on [sharing .env files with a team](https://crunu.com/guides/share-env-files-with-a-team).

## Why it drifts

Adding a variable touches three places:

1. **The code** that reads it, such as `process.env.PAYMENT_WEBHOOK_URL`.
2. **The real environments**: someone's local `.env`, staging, production, CI.
3. **The example file** that tells the next person the name exists.

Only the first one goes through review with any reliability. The second is done by whoever deploys, often from a chat message. The third is easy to skip, because the application runs fine without it. Over a few months, staging gains a variable that production doesn't have, production keeps one that the code stopped reading, and `.env.example` reflects neither. The glossary calls this [configuration drift](https://crunu.com/learn/configuration-drift).

The symptoms show up late: a deploy that fails on a missing variable, a feature that silently uses a default in one environment, or a new hire who spends a morning collecting values from colleagues.

## What "in sync" means

Values differ between environments by design. Production has a live payment key and staging a test one. Being in sync is about the **set of names** of [environment variables](https://crunu.com/learn/environment-variable):

- every name the code requires appears in `.env.example` and in every environment where the code runs;
- every name in an environment is one the code still reads, or is known to be used by a tool;
- deliberate differences are written down, such as `DEBUG_SQL` existing only in development.

That gives you three comparisons: the example against the code, the example against each environment, and environments against each other. Each one compares names only.

## Comparing names, not values

Extracting names from a `.env` file takes one line, as long as it skips comments and an optional `export` prefix:

```sh
names() { sed -E 's/^[[:space:]]*export[[:space:]]+//' "$1" | grep -oE '^[A-Za-z_][A-Za-z0-9_]*=' | tr -d '=' | sort -u; }

names .env.example > example.names
names .env > local.names
comm -3 example.names local.names
```

`comm -3` prints names that exist on only one side: indented ones are in your `.env` but not in the example, the others are in the example but missing locally. Nothing in the output is a value.

Two tools do the same with less typing. `dotenv-linter`, an MIT-licensed linter, has a `diff` command: `dotenv-linter diff .env .env.example` prints lines such as ".env is missing keys: BAR" ([dotenv-linter](https://github.com/dotenv-linter/dotenv-linter)). In the browser, the [env diff tool](https://crunu.com/tools/env-diff) shows which variables exist in only one of two pasted files and which have different values, with values hidden until you ask and a copied report that lists names only. The [env linter](https://crunu.com/tools/env-linter) catches duplicated names and malformed lines, which make any comparison less reliable.

## Making CI fail on a missing name

The example file becomes useful the moment something enforces it. A short script can check, at the start of a test or deploy job, that every name listed in `.env.example` is set in the job's environment:

```sh
#!/bin/sh
# scripts/check-env.sh: exits 1 if a name from .env.example isn't set
missing=0
for name in $(sed -E 's/^[[:space:]]*export[[:space:]]+//' .env.example | grep -oE '^[A-Za-z_][A-Za-z0-9_]*'); do
  if ! printenv "$name" > /dev/null; then
    echo "missing: $name"
    missing=1
  fi
done
exit $missing
```

It prints names, never values, so it's safe in a log. Run it with the environment the job will really use. With Crunu, that's the same `crunu run` the job runs anyway:

```sh
crunu run --env web-app/staging -- ./scripts/check-env.sh
```

If a name is missing from staging, the job stops before the tests, with the name in the log. If you need some names to be optional, mark them in `.env.example` with a comment convention, such as `# optional` on the line above, and have the script skip them.

## Finding names the example doesn't list

The other direction, names the code reads that the example forgot, is harder to automate fully, but a search gets most of the way. For a Node.js project:

```sh
grep -rhoE 'process\.env\.[A-Z_][A-Z0-9_]*' src | sed 's/process\.env\.//' | sort -u > code.names
comm -23 code.names example.names
```

`comm -23` prints names that appear in the code but not in the example. Adjust the pattern for your language: `os.environ` and `os.getenv` in Python, `ENV[` in Ruby, `os.Getenv` in Go, `env(` in Laravel's config files. It won't catch names built at run time, but it catches the ordinary ones. Running it in review, rather than in CI, can be enough.

## Comparing the real environments

The last comparison is between the environments themselves, and that's where a file-based approach struggles: comparing production with staging means having both files in one place, with real values, which is exactly what you want to avoid.

In Crunu, **Compare environments** puts two environments side by side, even from different projects. Counters at the top show how many variables differ, how many exist only on one side, and how many are identical. Values stay hidden until you choose to show a row, and the counters and labels are computed in your browser, so the server learns nothing about which values match ([compare and promote environments](https://crunu.com/docs/guides/compare-and-promote-environments)).

*Figure: Environment comparison for payments-api at Kiteline, a fictional company: staging on the left, production on the right. Chips count 6 different variables, 1 only in production, 1 only in staging, 2 identical and 2 files only in production. Every value stays hidden behind dots. OPENAI_API_KEY, which exists only in staging, is checked, and the cursor rests on Copy → to production.*

When a name is missing on one side, select it and copy it across. The copy is saved as a new, signed version of the target environment, and the previous values stay in its history. Copying into production needs the Editor role there, so a developer who can read staging can spot the gap without being able to change production.

The comparison works on what the server can't see. It stores each environment as ciphertext, one encrypted record per version, and has no list of variable names to compare. The browser decrypts both environments locally, which is why the comparison is possible at all without trusting the server.

*Figure: Two sides of one variable at Kiteline, a fictional company. Node 1, this device: Priya Natarajan’s browser shows STRIPE_SECRET_KEY decrypted on this device as sk_live_EXAMPLE_not_a_real_key, while the rows around it stay hidden. Node 2, what the server stores for payments-api / production: one env-version record with version 15, key epoch 2, a padded block of ciphertext, the author and a signature. Variable names and values exist only inside the ciphertext; the server sees the project and environment names and logs a client-reported “Value shown”. The link between the nodes carries ciphertext only.*

Starting a new environment is the other moment drift begins. Creating it as a copy with **Variable names only, no values** gives it the full list of names from an existing environment, and you fill in the values yourself. A new `production` starts with every name `staging` has.

## The lists you forget

`.env.example` is often not the only list of names in a project. Others drift the same way:

- **Compose files.** An `environment:` block in `docker-compose.yml` names variables explicitly. A new name in the code but not in that block never reaches the container, even when the `.env` next to it has the value.
- **CI settings.** Repository and pipeline variables in GitHub, GitLab or Bitbucket are another set of names, edited in a web page and invisible to review.
- **Hosting dashboards.** Platform variables on Vercel, Netlify, Render or Fly.io are yet another set. Some of their command-line tools list names without printing values, such as `fly secrets list`, which is enough for the same `comm` comparison.
- **Documentation.** A table of variables in the README is a second example file that nobody tests.

You don't need a check for every list. The `check-env.sh` step catches a missing name wherever the job's environment comes from, so running it in each place your code starts, a CI job, a container entry point, a deploy hook, covers most of them. The README table can simply go: point to `.env.example` instead, and keep one list.

## A routine that holds

- **In the pull request.** Any change that adds `process.env.X` or its equivalent adds a line to `.env.example`. Put it in the pull request template.
- **In CI.** `check-env.sh` runs before tests and before deploys, with the real environment.
- **Before a release.** Compare staging with production and resolve every "only in" row, either by copying or by writing down why it differs.
- **When removing a variable.** Remove it from the code, the example and every environment in the same change, so the next comparison is clean.

The glossary entry on the [.env.example file](https://crunu.com/learn/env-example-file) has the short definition.

## Checklist

- [ ] `.env.example` lists every name the code requires, with no real values
- [ ] CI checks that every listed name is set, printing names only
- [ ] A search for names in the code runs in review
- [ ] Staging and production are compared before each release
- [ ] Deliberate differences between environments are documented
- [ ] New environments start from names only, never from copied values

## Sources

- [dotenv-linter, GitHub](https://github.com/dotenv-linter/dotenv-linter), checked October 30, 2026.
