DEV Community

Cover image for Why .env.example Gets Out of Sync — and What Actually Fixes It
Rabbil Yasar Sajal
Rabbil Yasar Sajal

Posted on Originally published at envshield.dev

Why .env.example Gets Out of Sync — and What Actually Fixes It

Quick answer

.env.example goes out of sync because it's a plain text file with no way to enforce that it matches your code — nothing runs when a variable is added, removed, or made conditional. The fix isn't a better example file; it's a schema: a single, versioned file that declares what your configuration is actually supposed to be, which a tool then generates the example file from and validates everything else against. In EnvShield, that schema is env.schema.tomlenvshield schema sync generates .env.example from it, and envshield schema sync --check fails the moment the two drift, typically wired into a pre-commit hook so it's caught before the commit lands, not after a teammate hits it.

The problem

Open almost any repo's .env.example and you'll find the same story: it was accurate the day someone wrote it, and it's been quietly drifting ever since. A teammate adds STRIPE_WEBHOOK_SECRET to the payment service, the PR gets reviewed for logic, and nobody thinks to update the template — why would they? It's not code. Six weeks later someone clones the repo, copies .env.example to .env, runs the app, and it crashes on a variable the file never mentioned.

Search for this problem and you'll find a lot of people asking variations of the same three questions: how do I keep .env and .env.example in sync, why does .env.example keep missing environment variables, and is there a better way to do environment variable documentation than a hand-maintained text file. This article is about why the file drifts in the first place, and what actually stops it — not just what to rename it.

Why it happens

.env.example isn't badly designed. It's just structurally unable to enforce itself. It's a text file with no relationship to the code that reads the variables it lists, no relationship to the deployment manifest that's supposed to provide them, and no mechanism that runs when either of those changes. Someone has to remember to update it, by hand, every single time — and "someone has to remember" is exactly the kind of process that survives right up until it doesn't.

The result is a file that's simultaneously trusted and untrustworthy. New teammates copy it assuming it's complete. It usually isn't. Not because anyone was careless, but because nothing in the workflow ever made keeping it current cheaper than ignoring it.

What people do about it today

A few common approaches, and where each one runs out:

Manual discipline and code review. Ask reviewers to check for new env vars during PR review. Works until the variable is added inside a conditional branch nobody exercises in the diff, or a reviewer just misses it — which, over enough PRs, is a when, not an if.

A comment or checklist in the README. "Remember to update .env.example when you add a variable." This is a policy, not a mechanism. Policies that depend on memory have the same failure mode as the problem they're meant to solve.

A custom sync script. Some teams write a script that diffs .env against .env.example and fails CI if they don't match. This genuinely helps — it catches drift between two files. What it can't do is tell you a variable is missing from both of them, because it only knows about keys, not requirements. It can't tell you PORT=banana is invalid, and a hand-maintained .env.example has no way to express that a variable is only required when another flag is set — at best it can carry that as a comment someone wrote once; nothing enforces it.

Schema-based .env tooling. This is the right direction, and it's not just EnvShield's idea — Varlock, for one, takes the same core position: a committed schema, not a hand-maintained example file, should be the source of truth, and it adds real things on top of that (type-safe resolution, automatic secret redaction in logs). If your stack is JS/TS-centric and your concern is specifically keeping .env files honest inside that ecosystem, it's worth a look. EnvShield takes the same schema-as-contract idea further in a different direction: the same schema also validates against a Docker Compose or Kubernetes manifest, a second service written in a different language, and the actual git history of what the contract used to require — not just a local .env file. That's the gap the rest of this article is about.

The fix: a schema as the source of truth, not a description of one

The actual fix isn't a better .env.example. It's removing .env.example from being anyone's source of truth at all, and replacing it with something that can be validated instead of just read.

Concretely: a schema — a single, versioned, machine-readable file — declares what a project's configuration is supposed to be. Everything else (a local .env, a generated template, a deployment manifest) gets checked against it. Nobody maintains the template by hand anymore, because it isn't a document anyone writes — it's an output.

Here's what that looks like as env.schema.toml, EnvShield's implementation of the idea — this article stays focused on the sync problem specifically; for the fuller walkthrough of the contract itself, see The End of .env Chaos:

[DATABASE_URL]
description = "PostgreSQL connection string"
type = "url"
secret = true

[PORT]
description = "Port the API listens on"
type = "port"
defaultValue = "8000"

[LOG_LEVEL]
description = "Controls the application's log verbosity"
defaultValue = "info"

[STRIPE_SECRET_KEY]
description = "Stripe secret key -- only needed once payments are turned on"
secret = true
requiredIf = { var = "PAYMENTS_ENABLED", equals = "true" }
Enter fullscreen mode Exit fullscreen mode

Notice DATABASE_URL has neither a default nor a condition — that's what makes it required. There's no separate required = true field to remember: a variable is required unconditionally unless a defaultValue waives it or a requiredIf makes it conditional on another variable's value. type gives check something to actually validate against (PORT=banana fails, rather than surfacing three services downstream). requiredIf is what a static example file structurally can't express — STRIPE_SECRET_KEY is optional while PAYMENTS_ENABLED=false, and becomes required the moment that flag flips, in any environment.

The schema is not the example file

This is the distinction that matters most, because collapsing it back into "just a nicer .env.example" undoes the whole point: env.schema.toml is the contract. .env.example is a rendering of it.

$ envshield schema sync

Generating .env.example from schema...
✓ Successfully created/updated .env.example!

Next step:
  Review the updated template, then commit it.
  If it added a variable you need locally, run 'envshield setup'.
Enter fullscreen mode Exit fullscreen mode

That command generates the actual file:

# This file was auto-generated by EnvShield on <generation timestamp>
# It is generated from the contract defined in env.schema.toml.
# DO NOT EDIT THIS FILE MANUALLY.

# PostgreSQL connection string
# secret
DATABASE_URL=

# Port the API listens on
PORT=8000

# Controls the application's log verbosity
LOG_LEVEL=info

# Stripe secret key -- only needed once payments are turned on
# secret; required if PAYMENTS_ENABLED = "true"
STRIPE_SECRET_KEY=
Enter fullscreen mode Exit fullscreen mode

Edit that file by hand and the next schema sync silently overwrites your change — which is correct, not a bug, because once a schema exists, .env.example is generated output, not something anything else reads. (An existing .env.example can still be one of the inputs envshield init reads to seed a schema in the first place, on a project that doesn't have one yet — the "output, not source" rule is about what happens after the schema exists, not before.) The variable that actually stops drift is --check:

$ envshield schema sync --check'.env.example' is in sync with schema.
Enter fullscreen mode Exit fullscreen mode

Add a field to the schema without regenerating the template, and the same command tells you exactly what's missing instead of letting it slip through:

$ envshield schema sync --check
✗ Missing from '.env.example': NEW_FEATURE_FLAG (run 'envshield schema sync' to regenerate it)
Enter fullscreen mode Exit fullscreen mode

Wire that into envshield hook install and it runs automatically before a commit lands — but only for a schema you actually staged, so it never blocks a commit that didn't touch configuration.

What secret = true actually does — and doesn't

Worth being precise here, because it's the field people most often over-assume about. secret = true tells EnvShield a field is sensitive. It does not, and structurally cannot, hold the value — the schema only ever describes the shape of configuration, never real values. The actual key lives in your local, git-ignored .env, nowhere else.

What the flag buys you: .env.example shows # secret instead of a value, and any code EnvShield generates treats the field as sensitive too. That's the feature, in full. EnvShield doesn't store secret values, doesn't retrieve them from anywhere, and isn't where you'd go to rotate one. If you need an actual vault, that's what 1Password, HashiCorp Vault, or AWS Secrets Manager are for — this is a contract describing a secret, not a place to keep one.

Validating that the contract actually holds

Once the schema exists, check is what enforces it against a real local file:

$ envshield check

Validating .env against schema...
┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Status        ┃ Variable Name ┃ Source                             ┃
┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ Invalid Value │ PORT          │ must be a port number from 1-65535 │
└───────────────┴───────────────┴────────────────────────────────────┘

Suggestion: Run 'envshield setup' to fill in missing/blank values or fix invalid ones.
Enter fullscreen mode Exit fullscreen mode

This is the check a text file can't do. A stale .env.example never had an opinion on whether PORT=banana is valid — it doesn't know what a port is. A schema does, because type is a real constraint, not a comment.

The next layer: what about variables nobody declared at all?

Everything so far assumes someone remembered to add the variable to the schema in the first place. That's a real gap on its own — a schema you have to remember to update by hand has just moved the discipline problem one level up, not solved it. That's a distinct problem from the one this article is about, and EnvShield has a separate command for it (envshield undeclared, which catches a variable your code just started reading that the schema doesn't know about yet) — worth its own explanation rather than a paragraph tacked onto this one.

When this approach makes sense — and when it doesn't

A schema is overhead you don't need for a two-variable prototype script you'll delete next week, or a project with exactly one contributor who already holds the whole configuration in their head. In those cases, a plain .env and a five-minute memory are genuinely fine — don't add a contract to solve a problem you don't have yet.

It starts paying for itself once any of these are true: more than one person touches the project, configuration differs by environment (local vs. staging vs. production), or a deployment manifest needs to independently agree with the application about what it requires. That's also roughly where a hand-maintained .env.example starts failing anyway — the timing isn't a coincidence.

A practical migration path

  1. envshield init, whether or not you already have real configuration. It builds env.schema.toml from an existing .env, .env.example, or a Python config module when one of those exists — and falls back to a framework-aware template on a genuinely fresh project with none of that yet. Either way, it also writes envshield.yml, registers a deployment manifest if it finds one, updates .gitignore, and offers to install Git hooks.
  2. Review the generated schema. Mark real secrets with secret = true, add defaultValue for anything genuinely optional, and requiredIf for anything conditional. This is the one manual step, and it only happens once per variable, not once per drift.
  3. envshield schema sync, then commit both env.schema.toml and the generated .env.example together.
  4. envshield check against your own local file to confirm nothing's actually missing or malformed right now.
  5. envshield hook install so a schema change without a regenerated template gets caught before it's committed, not after someone else hits it.

None of this requires believing configuration is glamorous. It requires believing that "what does this project actually need to run" shouldn't depend on someone remembering to update a text file.

pip install envshield
Enter fullscreen mode Exit fullscreen mode

GitHub · Docs · Website

Top comments (0)