Where .env Went Wrong
.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
Section titled “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
Section titled “A string is not a schema”Consider a typical example file:
DATABASE_URL=REDIS_URL=redis://localhost:6379STRIPE_API_KEY=DEBUG=falseThe 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
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.productionThe 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
Section titled “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?
Section titled “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?
Section titled “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
Section titled “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
Section titled “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
Section titled “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" }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 -- ./serverThis 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:.envThis copies names without copying values. The current file can remain a provider during the transition:
secretspec check --provider dotenv:.envsecretspec run --provider dotenv:.env -- ./serverValues 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.