Where .env Went Wrong
Jul 30, 2026
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
project’s configuration schema, secret store, environment model,
onboarding guide, CI interface, and deployment format.
Environment variables do one job well: deliver strings to a process.
.env turned that delivery mechanism into a source of truth.
A convenience became architecture. That is where .env went wrong.
Environment variables only deliver values
Environment variables solve a small problem: getting values into a
running process. The application can read DATABASE_URL without knowing
whether a developer, CI system, or secrets manager supplied it.
A .env file makes those values easy to save and reload. That is
useful. But teams also use the file to describe what the application
needs. KEY=value cannot say whether a value is required, secret, safe
to commit, available only in production, or restricted to one service.
Those requirements outlive any process and any developer laptop. They
belong in a durable project declaration. .env stores values for
delivery; it cannot define the application’s secret model.
A string is not a schema
Consider a typical example file:
DATABASE_URL=REDIS_URL=redis://localhost:6379STRIPE_API_KEY=DEBUG=false
.env.example
The file raises more questions than it answers. Does an empty value mean
required or optional? Is REDIS_URL a development default? Is
STRIPE_API_KEY production-only? Is DEBUG a boolean?
Dotenv cannot encode those answers. Node.js documents that every value
becomes a
string.
A dotenv issue about
booleans, opened in 2015,
still collects reactions from developers surprised that "false" is
truthy.
Teams put the missing information elsewhere: validation code, a README,
.env.example, or a teammate’s memory. These sources drift.
The file also makes DEBUG and STRIPE_API_KEY look equivalent. One is
an ordinary setting that belongs in Git. The other grants authority and
needs access control and rotation. Mixing them makes the whole file
sensitive.
Without an explicit declaration, missing values fail late: the application discovers them only when code tries to use them.
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 defines inheritance, and copying a file becomes deployment.
This reverses the Twelve-Factor App’s
guidance. Its point was that environment
variables should be independent controls because named environments
become brittle as deployments multiply. .env.production recreates that
grouping in a filename.
Now every new value must be added to .env.example, documented in a
README, validated in code, and copied into the right real files. Miss
one and the environments drift.
There is no .env spec
.env looks standardized, but every parser defines its own format.
Node.js documents the lack of a formal
specification,
as does
python-dotenv.
Each loader makes its own choices.
python-dotenv expands ${NAME} but not $NAME. Node dotenv delegates
variable
expansion to
another tool. Docker Compose supports its own shell-style
operators.
Vite even supports references in reverse order, then
warns
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 unquoted values in version 15 as a breaking
change. One devenv user
found that quotes became part of an exported
key.
Which value wins?
Parsers also disagree about precedence. Node
dotenv normally lets the first
file win. Docker
Compose
lets the last env_file win, then lets the environment section
override that.
Vite
gives an existing process variable priority over its files.
Docker Compose gives two similar names different behavior. env_file:
supplies variables to a container but does not use them to interpolate
compose.yaml. docker compose --env-file does affect interpolation.
In an issue closed as working as
designed, a maintainer
described the option’s name as unfortunately chosen.
Who loaded .env first?
Precedence also depends on timing. Node dotenv’s ES module
guidance
needs special handling when imported modules read the environment during
initialization. Vite warns that Bun’s automatic .env loading can
interfere with Vite’s own loading
order. VITE_*
values are replaced at build time and become part of the client
bundle.
The same line can become a runtime secret, a build-time constant, or a public browser value. The loader decides based on timing and context.
At that point .env behaves like a small program, with control flow
spread across filenames, flags, working directories, parent processes,
and library versions.
An ignored file is still a file
The dotenv project says not to commit
.env.
.gitignore prevents one accident. It does not add encryption, access
control, auditing, or revocation.
The file can still end up in editor backups, chat messages, archives,
support bundles, container build contexts, and old laptops. A devenv
integration was reported to copy .env contents into the Nix
store, where paths are
not confidential. When a developer leaves, there is no file access to
revoke. Each credential they received is a separate copy.
Even a secret stored in 1Password, Vault, a cloud secret manager, or a system keyring must be copied into plaintext before a dotenv-based application can use it. The local copy has fewer controls than the original.
Environment-variable delivery has limits too. Docker mounts managed secrets as files because environment variables can leak between containers. A process also gets one global map, so a frontend build, worker, migration, and web service often receive the same secrets even when each needs only a few. Dotenv has no way to express that scope.
Let .env become small again
The useful part of .env is the short path from “this application needs
a value” to “the application can run.”
Keep it as an adapter for tools that expect KEY=value, or use it for
ordinary local settings. Do not make it define the project’s
requirements, store durable copies of secrets, encode environments in
filenames, or decide which services receive which values.
A durable design separates three jobs:
- a committed declaration says which secrets the application needs;
- protected storage controls who can read their values;
- explicit delivery gives each process only the values it needs.
Each piece can then change independently. A team can change storage without rewriting the application, validate requirements before startup, and limit each component to its own secrets.
How SecretSpec applies this
SecretSpec puts the declaration in a file that is safe to commit:
[project]name = "payments"revision = "1.0"
[profiles.default]DATABASE_URL = { description = "Postgres connection string" }REDIS_URL = { description = "Redis connection string", required = false }STRIPE_API_KEY = { description = "Stripe API key" }
[profiles.development]REDIS_URL = { default = "redis://localhost:6379" }
secretspec.toml
This file records requirements, defaults, and descriptions without containing secret values. Providers choose where values live, and profiles describe real differences in requirements.
Existing programs can adopt SecretSpec without code changes:
$ secretspec run -- ./server
Terminal window
This command injects resolved secrets into the child process environment. It is useful during migration, while the preferred integration is a SecretSpec SDK.
With an SDK, the application resolves its declaration directly. This
removes the environment-variable handoff used by secretspec run.
Values stored in a keyring, password manager, or Vault never enter the
global process environment. Applications that require a file can receive
a temporary file instead.
Scopes (0.17+) let each component resolve only the
secrets it declares.
Migration can be gradual. SecretSpec initializes a declaration from an existing file:
$ secretspec init --from dotenv:.env
Terminal window
This copies names without copying values. The current file can remain a provider during the transition:
$ secretspec check --provider dotenv:.env
$ secretspec run --provider dotenv:.env -- ./server
Terminal window
Values can then move to a system keyring, password manager, Vault, or another provider without changing the names the application reads.
.env can remain for ordinary local settings. Existing applications can
keep environment-variable delivery while they migrate. Applications
using an SDK or file-based delivery can remove secrets from their
process environments.
SecretSpec aims to eliminate environment variables for secrets altogether.
Last updated Oct 08, 2026