Let's be honest: .env.example files have a reputation problem. Everyone knows they're important and nobody quite trusts them. Mine's usually out of date within a month — missing whatever variable got added in a PR nobody reviewed for configuration changes.
But here's a distinction worth making up front, because getting it wrong is exactly why .env.example stays broken: .env itself isn't the problem. A key-value file is a perfectly fine way to hold configuration at runtime. The actual problem is that nothing declares, in one place, what a project's configuration is supposed to look like. Requirements are implicit — they live in application code, in a deployment manifest, in a .env.example someone wrote by hand once, and in a senior developer's memory of "oh, you also need STRIPE_SECRET_KEY if payments are on." None of those four places are required to agree with each other. They usually do, right up until they don't.
That's what a configuration contract actually fixes — not "cleaner env files," but one real, versioned, checkable description of what a project needs, that everything else (code, deployment manifests, a new teammate's local setup) gets validated against instead of just trusted.
env.schema.toml: the contract, not a description of one
EnvShield's answer is a file named env.schema.toml, committed to git, next to the code it configures:
[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" }
Five fields doing five different jobs:
-
type— the value's shape (string,int,float,bool,port,url,email).check,setup, anddoctorall enforce it —PORT=bananafails validation, not at runtime three services downstream. -
defaultValue— whatsetupwrites automatically if nobody provides one. The variable still has to be present in the local file; the default just meanscheckstops complaining once it is. -
description— shown insetup's prompt and copied into any generated config code. -
secret— marks a field sensitive. More on exactly what that does below, because it's easy to over-assume. -
requiredIf— makes a field required only when another variable's value says it should be.STRIPE_SECRET_KEYabove is optional whilePAYMENTS_ENABLED=false, and becomes required the moment that flag flips, in any environment.
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 to set: a variable is required unconditionally unless a defaultValue waives it or a requiredIf makes it conditional.
What secret = true actually does (and doesn't)
Worth being precise here, since it's easy to read past: secret = true tells EnvShield this field is sensitive. It does not, and structurally cannot, hold the value itself — the schema only ever describes the shape of your configuration, never the actual values. The real key lives in your local, git-ignored .env, nowhere else.
What the flag buys you: setup masks your keystrokes when prompting for it, import never guesses a real value into the committed schema, and any code EnvShield generates treats the field as sensitive too. That's the whole feature. 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 secrets vault, that's what 1Password, HashiCorp Vault, or AWS Secrets Manager are for. EnvShield's job is making sure the contract around a secret is honest, not holding the secret itself.
env.schema.toml vs. .env.example
This is the distinction the rest of this article depends on: .env.example is a representation of the contract. env.schema.toml is the contract.
envshield schema sync
generates .env.example straight from the schema — documentation for a human skimming the repo, not something EnvShield itself reads back. Edit it by hand, and the next schema sync overwrites your change, because it's an output, not a source. Every command that actually validates or enforces anything — check, setup, doctor, undeclared, schema diff, generate — reads env.schema.toml directly. If you've ever watched a .env.example drift from reality within a month, this is why: it never had a mechanism to stay honest on its own. envshield hook install wires a pre-commit check (schema sync --check) that catches a schema edited without regenerating its template — but that's something you opt into, not something that happens automatically just because a schema exists.
One contract, several workflows
Every command below reads the same file — none of them invent their own idea of what your configuration is supposed to be:
env.schema.toml
↓
validation · discovery · documentation · onboarding · review · deployment checks
-
envshield init— builds the schema from your real, existing config (a.env, a Python config module, or a deployment manifest) if one exists, or a framework-aware template if it doesn't. -
envshield setup— interactive onboarding: prompts for whatever's missing, masks secret input, writes a local file. -
envshield check— validates a local file, or a Docker Compose or Kubernetes manifest, against the schema. -
envshield schema sync— regenerates.env.examplefrom the schema. -
envshield undeclared— catches a variable your code just started reading that the schema doesn't know about yet. -
envshield schema diff— compares the contract itself across two git revisions, classified by impact (breaking, security-sensitive, informational).
A concrete example: catching a variable before it's committed
Say this line shows up in app/main.py:
analytics_key = os.getenv("ANALYTICS_KEY")
Nobody's added ANALYTICS_KEY to env.schema.toml. It hasn't even been committed yet. Running:
$ envshield undeclared
┌────────────────┬─────────────┬──────┬───────────┬──────────────────────┐
│ Variable │ File │ Line │ Access │ Contract status │
├────────────────┼─────────────┼──────┼───────────┼──────────────────────┤
│ ANALYTICS_KEY │ app/main.py │ 42 │ os.getenv │ missing declaration │
└────────────────┴─────────────┴──────┴───────────┴──────────────────────┘
By default, undeclared compares your working tree against HEAD — including uncommitted and untracked files — so this is caught before git add, not after a teammate hits it in staging.
scan does something related but broader: it inventories every currently-undeclared read across the whole codebase in one pass, alongside hardcoded-secret detection — useful for a first audit of an existing project. undeclared's job is narrower and more precise: what's new since a given revision, which is what makes the example above possible.
Deployment manifests get checked too
The same schema also validates the manifest that actually deploys a service, not just a local file:
envshield check docker-compose.yml
envshield check k8s/deployment.yaml --container api
Docker Compose's ${VAR}/${VAR:-default} interpolation is resolved; Kubernetes Deployments, StatefulSets, DaemonSets, Jobs, CronJobs, and bare Pods are supported, including multi-document manifests. A handful of narrower cases aren't handled yet — the no-colon ${VAR-default} Compose form, Kubernetes secretKeyRef/configMapKeyRef matched by key rather than env-var name, envFrom.prefix. None of them produce a false-clean on a genuinely missing required variable; they're just not resolved yet.
The takeaway
None of this requires believing configuration is glamorous. It requires believing that "what does this project actually need to run" shouldn't be an oral tradition. A schema doesn't add a sixth place to keep configuration description in sync by hand — it gives the other five (code, .env, .env.example, the deployment manifest, and whatever a teammate remembers) something to actually be checked against.
pip install envshield
Top comments (0)