Where .env Went Wrong | SecretSpec<br>Skip to content
Where .env Went Wrong
Jul 30, 2026<br>Domen Kožar
.env is one of software’s most successful accidents.
It starts as a shortcut for three export commands. Then it becomes the<br>project’s configuration schema, secret store, environment model, onboarding<br>guide, CI interface, and deployment format.
Environment variables do one job well: deliver strings to a process. .env<br>turned that delivery mechanism into a source of truth.
A convenience became architecture. That is where .env went wrong.
Environment variables only deliver values<br>Section titled “Environment variables only deliver values”
Environment variables solve a small problem: getting values into a running<br>process. The application can read DATABASE_URL without knowing whether a<br>developer, CI system, or secrets manager supplied it.
A .env file makes those values easy to save and reload. That is useful. But<br>teams also use the file to describe what the application needs. KEY=value<br>cannot say whether a value is required, secret, safe to commit, available only<br>in production, or restricted to one service.
Those requirements outlive any process and any developer laptop. They belong<br>in a durable project declaration. .env stores values for delivery; it cannot<br>define the application’s secret model.
How does SecretSpec solve this?<br>SecretSpec separates the committed<br>declaration from value<br>storage and delivery. The CLI and<br>SDKs resolve the same declaration regardless of where values<br>live or how applications receive them.
A string is not a schema<br>Section titled “A string is not a schema”
Consider a typical example file:
.env.exampleDATABASE_URL=
REDIS_URL=redis://localhost:6379
STRIPE_API_KEY=
DEBUG=false
The file raises more questions than it answers. Does an empty value mean<br>required or optional? Is REDIS_URL a development default? Is<br>STRIPE_API_KEY production-only? Is DEBUG a boolean?
Dotenv cannot encode those answers. Node.js documents that<br>every value becomes a<br>string.<br>A dotenv issue about<br>booleans, opened in 2015, still<br>collects reactions from developers surprised that "false" is truthy.
Teams put the missing information elsewhere: validation code, a README,<br>.env.example, or a teammate’s memory. These sources drift.
The file also makes DEBUG and STRIPE_API_KEY look equivalent. One is an<br>ordinary setting that belongs in Git. The other grants authority and needs<br>access control and rotation. Mixing them makes the whole file sensitive.
Without an explicit declaration, missing values fail late: the application<br>discovers them only when code tries to use them.
How does SecretSpec solve this?<br>The SecretSpec declaration records names,<br>descriptions, required values, and safe defaults. secretspec check and<br>secretspec run validate those requirements before the application starts,<br>while ordinary settings such as DEBUG remain in application configuration.
Then the file starts to multiply<br>Section titled “Then the file starts to multiply”
A new requirement usually creates another file:
.env
.env.local
.env.development
.env.development.local
.env.test
.env.production
The filenames become an environment model. Suffixes define scope, load order<br>defines inheritance, and copying a file becomes deployment.
This reverses the<br>Twelve-Factor App’s guidance. Its point was that<br>environment variables should be independent controls because named<br>environments become brittle as deployments multiply. .env.production<br>recreates that grouping in a filename.
Now every new value must be added to .env.example, documented in a README,<br>validated in code, and copied into the right real files. Miss one and the<br>environments drift.
How does SecretSpec solve this?<br>Profiles express real requirement differences as sparse<br>overlays on profiles.default. Each deployment selects its values through<br>providers instead of maintaining a complete, copied secret file.
There is no .env spec<br>Section titled “There is no .env spec”
.env looks standardized, but every parser defines its own format.<br>Node.js documents the lack of a formal<br>specification,<br>as does<br>python-dotenv. Each<br>loader makes its own choices.
python-dotenv expands ${NAME} but not $NAME. Node dotenv delegates variable<br>expansion to another<br>tool. Docker Compose supports its own<br>shell-style operators.<br>Vite even supports references in reverse order, then<br>warns<br>that the same expression will not work in a shell or Docker Compose.
Comments and quotes differ too. Node dotenv changed the meaning of # in<br>unquoted values in version 15 as a breaking<br>change. One devenv user found<br>that<br>quotes became part of an exported<br>key.
How does SecretSpec solve this?<br>SecretSpec defines one TOML declaration and one resolution model shared by its<br>CLI and SDKs. Dotenv parsing is confined to the<br>compatibility provider, so changing loaders or storage<br>backends does not change the application’s declaration.
Which value wins?<br>Section titled “Which value wins?”
Parsers also disagree about precedence.<br>Node...