Skip to content

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 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.

Consider a typical example file:

.env.example
DATABASE_URL=
REDIS_URL=redis://localhost:6379
STRIPE_API_KEY=
DEBUG=false

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.

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.

.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.

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.

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.

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.

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.

SecretSpec puts the declaration in a file that is safe to commit:

secretspec.toml
[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:

Terminal window
secretspec run -- ./server

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:

Terminal window
secretspec init --from dotenv:.env

This copies names without copying values. The current file can remain a provider during the transition:

Terminal window
secretspec check --provider dotenv:.env
secretspec run --provider dotenv:.env -- ./server

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.