the long version

Anatomy of a bad .env.example

Nobody writes a bad example file on purpose. The format simply has no way to express the things you need it to express, so every project reinvents them in comments, in a README, or in the head of whoever set it up. Here are the seven gaps, and what closing them looks like.

← grade your own file

01

It cannot say what is required

Every line looks the same. "The app will not boot without this" and "this is an optional feature flag" are formatted identically, so the difference lives in someone's memory until a deploy finds out for you.

.env.example
DATABASE_URL=
ANALYTICS_ID=
.env.schema
# @required
DATABASE_URL=

# @optional
ANALYTICS_ID=

02

It cannot say what is a secret

This is the expensive one. If nothing knows which values are sensitive, nothing can mask them — not your logs, not your error reporter, not your CI output, and not the context you hand to a coding agent. Redaction has to be a property of the config, not a habit.

.env.example
SENTRY_DSN=https://abc@sentry.io/1
STRIPE_SECRET_KEY=sk_live_...
.env.schema
# @public
SENTRY_DSN=https://abc@sentry.io/1

# @sensitive
STRIPE_SECRET_KEY=

03

It cannot enforce a type

PORT is a string. TIMEOUT_MS is a string. FEATURE_X=flase is a string. Every env var starts life as text, and an example file has no way to say what it should become — so the coercion is scattered across your codebase, done slightly differently each time.

.env.example
PORT=3000
DEBUG=true
API_URL=localhost:4000
.env.schema
# @type=port
PORT=3000

# @type=boolean
DEBUG=true

# @type=url
API_URL=http://localhost:4000

04

It has no idea what environment you are in

Staging needs a different API URL. Production requires keys that local dev does not. An example file flattens all of that into one list, so the real rules live in a deploy dashboard, a wiki page nobody updated, and a Slack thread from March.

.env.example
API_URL=
# prod: https://api.acme.com
# staging: https://staging-api.acme.com
.env.schema
# @currentEnv=$APP_ENV
# ---
API_URL=if(eq($APP_ENV, production),
  https://api.acme.com,
  http://localhost:4000)

# @required=forEnv(production, staging)
DATADOG_API_KEY=

05

It invites you to commit a real key

The file is designed to be committed and is shaped exactly like the file that holds real values. Sooner or later someone fills one in "just to test" and pushes. Now the key is in git history forever, and rotating it is the only remedy. Because a schema marks the item @sensitive, its value is never committed in plaintext — you commit a reference to where it is fetched, or an encrypted value — so a live key never lands in git in the first place.

.env.example
# oops
STRIPE_SECRET_KEY=sk_live_51H8xQ2Kp...
.env.schema
# @sensitive @docs(https://docs.stripe.com/keys)
STRIPE_SECRET_KEY=op(op://eng/stripe/secret-key)

06

It cannot stop a secret from shipping to the browser

NEXT_PUBLIC_, VITE_, PUBLIC_, REACT_APP_ — these prefixes inline the value into JavaScript served to every visitor. An example file lists such a var next to a database password with no visual or structural difference. Declaring sensitivity is what makes the mistake catchable before the bundle ships.

.env.example
NEXT_PUBLIC_API_KEY=
.env.schema
# @public @type=string(startsWith=pk_)
NEXT_PUBLIC_STRIPE_KEY=pk_test_...

# @sensitive
STRIPE_SECRET_KEY=

07

It drifts, silently, starting the day it is written

A developer adds a var, uses it in code, sets it locally, ships. The example file is not enforced by anything, so nothing notices it was not updated. Two weeks later a new hire clones the repo, follows the file exactly, and gets a runtime error in a place that has nothing to do with config. That is not a discipline problem. It is a file that has no way to be wrong out loud.

.env.example
# .env.example, last updated 8 months ago
.env.schema
$ varlock load
✗ MISSING  RESEND_API_KEY  (required, used in src/mail.ts)

the one nobody planned for

Your coding agent reads these files

Every AI coding tool eventually runs cat .env, or greps for a var name, or includes the file in its context window because it was open in your editor. Once a secret is in a prompt it is in a request body, a provider's logs, and possibly a transcript you cannot delete.

The fix is structural rather than behavioural: keep the committed file free of values, mark which items are sensitive, and put a loader in front of them that masks anything marked sensitive before it reaches a terminal, a log line, or a model. That is what varlock does — varlock load --agent returns the whole environment as JSON with sensitive values redacted, which is what an agent should have been getting all along.