Every project I join has a .env.example. It lists forty variable names, half of them with no value, and tells me nothing about which ones I actually need, what they should look like, or where I go to get them. I find out by running the app and reading stack traces.
That file is untyped text. So I wrote dottyenv, a small Rust CLI that validates a .env file against a schema. The schema says which variables are required, what they should look like, and where a person goes to get a missing one.
The problem is everything after the value exists
Nobody needs a tool to create an environment variable. The values come from providers: the Stripe dashboard, the AWS console, whoever runs the database. The pain is everything after that:
- Onboarding. A new developer clones the repo and has no idea which of the forty keys are required, which have safe defaults, or where the real values live.
- Drift. The code starts reading
REDIS_URL, nobody updates.env.example, and it breaks for the next person who clones. - Late failure. A missing variable shows up as a crash twenty minutes into a task instead of at startup.
- Malformed values. A key pasted with a trailing newline, or a test key in a production config, fails in ways that are slow to diagnose.
There are plenty of tools near this space. dotenvx and SOPS encrypt. Doppler and Infisical host and sync secrets. direnv loads them when you cd. dotenv-linter checks syntax. None of them treat .env.example as something worth typing, and none of them can tell you where to get a key you are missing.
What it looks like
The schema is a TOML file you commit next to the code. It holds the shape of the configuration, never the values:
[vars.DATABASE_URL]
required = true
pattern = '^postgres(ql)?://'
description = 'PostgreSQL connection string'
secret = true
[vars.STRIPE_SECRET_KEY]
required = true
pattern = '^(sk|rk)_(test|live)_'
description = 'Stripe secret key'
source = 'https://dashboard.stripe.com/apikeys'
secret = true
Then dottyenv check compares your .env against it. Here it is on a project where someone pointed the database at MySQL and never filled in the Stripe key:
$ dottyenv check
✗ 2 problems in .env
INVALID DATABASE_URL
expected ^postgres(ql)?://, got "<redacted, 37 chars>"
PostgreSQL connection string
MISSING STRIPE_SECRET_KEY
Stripe secret key
→ https://dashboard.stripe.com/apikeys
5 of 7 required variables OK
It exits 1, so it works as a CI gate or a pre-start check. A missing or broken schema exits 3, and a usage error exits 2, so a script can tell “your config is wrong” apart from “the tool is set up wrong”.
Never print the secret
DATABASE_URL is marked secret, so the error reports its length and nothing else. A connection string carries a password, and an error message is the last place it should show up. It ends up in CI logs, in screenshots, in a Slack thread asking for help. Length alone is enough to spot a paste error.
The same rule runs through the rest of the tool. A variable whose name contains SECRET, TOKEN, PASSWORD, PRIVATE, _KEY or CREDENTIAL is treated as secret even when its value tells you nothing. And one of the tests exists purely to check that a placeholder like SESSION_SECRET=changeme never gets echoed back into a generated schema, even as a suggestion.
The opposite case matters too. A Stripe publishable key is designed to ship to browsers, so it is not marked secret. Redacting it would hide a value you need to see to fix it.
A wrong pattern is worse than no pattern
Writing a schema by hand for forty variables is the kind of chore that means nobody does it, so dottyenv init writes one from your existing .env or .env.example:
$ dottyenv init --file .env.example
wrote dottyenv.toml (7 variables from .env.example)
review it, then run `dottyenv check`
It knows a small catalog of providers, compiled into the binary so there are no network calls. When it sees sk_test_ it knows that is a Stripe secret key, what the pattern is, and that the source is the API keys page in the Stripe dashboard. When it sees a URL scheme it can pattern on that. For anything else, it refuses to guess:
[vars.LOG_LEVEL]
required = true
# one_of = ["info", ...] # TODO: list the valid values, then uncomment
[vars.SESSION_SECRET]
required = true
secret = true
# TODO: add a pattern, or delete this line
That is deliberate, and it is the rule I care most about in the whole project. If init sees LOG_LEVEL=info, the tempting thing is to write one_of = ["info"]. Then the first person who sets it to debug gets told their config is invalid. A false error on a working value destroys trust in a validator faster than anything. So a guess becomes a comment for a person to finish, never a live rule.
The catalog follows the same idea. Every pattern matches a prefix and never the full format, because vendors change key lengths. A test fails the build if any catalog pattern anchors the end with $. AWS secret access keys have no prefix at all, so they get no pattern, but the catalog still knows they are secret and where to get them, which is most of the value.
The bug that taught me to trust values over names
The first version of the catalog matched on variable names as well as values. DATABASE_URL matched the Postgres entry by name. Then init met a project whose DATABASE_URL was not Postgres, and it wrote a schema that rejected the very .env it had been generated from.
The name DATABASE_URL does not tell you the format. It might be postgres://, mysql://, mongodb+srv://, a SQLite path, or Prisma’s file:./dev.db. So databases are now identified by the value only, and a name match is thrown away whenever the actual value contradicts it. A test now checks that a schema generated by init passes the file it came from.
Environments
SQLite locally and Postgres in production is one variable with two shapes, so the schema can override rules per environment:
[envs.development.vars.DATABASE_URL]
pattern = '^(sqlite:|file:)'
[envs.production.vars.DATABASE_URL]
pattern = '^postgres(ql)?://'
[envs.production.vars.STRIPE_SECRET_KEY]
pattern = '^sk_live_'
The environment comes from the filename, so .env.production gets the production rules. An overlay can change required, pattern, one_of and default, but not description, source or secret. A credential is not sensitive in production and harmless in development. And a misspelled --env is an error rather than a silent fallback, because falling back would quietly downgrade a production check.
Try it
brew tap mfung/tap
brew install dottyenv
dottyenv init
dottyenv check
There are also .deb and .rpm packages on the releases page. How those get built and tested is in Shipping a Rust CLI to Homebrew, Debian and RHEL. It is early, but init, check, list and scan all work. scan is the one that looks at your code for variables the schema does not know about, and how it does that without knowing any programming languages gets its own post.