Skip to content

Blog

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.

SecretSpec 0.17: Scopes, secrets caching, SOPS, age, and systemd credentials

SecretSpec 0.17 ships:

A profile describes how secrets resolve for an environment. A scope now describes which of those secrets one consumer may receive:

secretspec.toml
[profiles.default]
DATABASE_URL = { description = "Database" }
API_KEY = { description = "API key" }
QUEUE_TOKEN = { description = "Queue token" }
[scopes.api]
secrets = ["DATABASE_URL", "API_KEY"]
[scopes.worker]
secrets = ["DATABASE_URL", "QUEUE_TOKEN"]
Terminal window
secretspec run --scope api -- ./api
secretspec run --scope worker -- ./worker

Composed secrets may still read hidden dependencies to build a visible value, but those inputs are not exposed to the child. The same scope selection is available to check, export, and the SDK builders. Scopes minimize secret delivery; they are not an authorization boundary when the child itself holds provider credentials.

Carrying the selected scope through resolver requests and results required a breaking change to secretspec-ffi. All SecretSpec SDKs have been updated for 0.17 to support scopes, so applications should upgrade their SDK package and bundled native resolver together.

Many cloud providers take long enough to resolve a secret that their latency becomes part of every development command.

A single 1Password lookup can take roughly one second.

SecretSpec providers implement get_many so a backend can resolve several values together. Relatively few secret stores and CLIs expose a true bulk-read operation, however, so many providers still have to perform separate lookups.

Waiting on a remote service or its CLI every time makes check, run, and application startup feel slow, especially as a project grows.

A provider alias can now combine its authoritative fallback route with a local cache:

secretspec.toml
[providers]
vault = "vault://vault.example.com:8200/secret"
local = "keyring://secretspec/cache/{project}/{profile}/{key}"
fast_vault = {
fallback = ["vault"],
cache = { provider = "local", max_age = "8h" }
}
[profiles.default.defaults]
providers = ["fast_vault"]

Fresh entries avoid contacting the remote provider. A miss or expired entry falls through to Vault and refreshes the cache; writes update the authoritative provider first and then refresh or invalidate its cached copy. Route changes, reference changes, and writes that bypass the cached alias also invalidate the entry.

The cache is a real copy of the secret, so SecretSpec requires a distinct store that it can delete from and records ownership before changing an entry. secretspec cache clear [NAME] forces the next read back through the authoritative route.

Vault and OpenBao KV v2 caches are the only providers that handle max_age as server-side expiry properly.

None of SecretSpec’s current local providers has strong native support for expiry. They can remove an expired entry the next time SecretSpec sees it, but cannot ensure the local copy disappears at its deadline if SecretSpec never runs again.

Our planned FactorSeal provider in Future work is intended to close that gap with an explicit API for credential eviction among the other goals.

Profiles can now express credential alternatives directly:

secretspec.toml
[profiles.default]
PASSWORD = { description = "Password", required = { at_least_one = "auth" } }
ACCESS_TOKEN = { description = "Token", required = { at_least_one = "auth" } }
GITHUB_TOKEN = { description = "GitHub token", required = { exactly_one = "github_auth" } }
GITHUB_APP_KEY = { description = "GitHub App private key", required = { exactly_one = "github_auth" } }

The auth group accepts a password, an access token, or both. The github_auth group requires exactly one credential and rejects configurations that provide both the token and the app key.

SOPS brings the encrypted-file workflow from our recent SOPS comparison behind SecretSpec’s provider-independent CLI and SDKs. SecretSpec delegates encryption and decryption to the installed SOPS CLI, so existing SOPS key services and .sops.yaml creation rules remain in control.

The provider reads and writes YAML, JSON, dotenv, and INI files, supports a single shared file or {project} / {profile} path templates, and can source sensitive SOPS inputs such as age keys or cloud credentials through provider credentials.

secretspec.toml
[providers]
sops = "sops://secrets/{project}/{profile}.enc.yaml"
[profiles.production.defaults]
providers = ["sops"]

age offers a smaller encrypted-file setup. It stores a dotenv-style secret set for one or more age recipients, including hybrid post-quantum recipients.

KeePass KDBX reads KDBX 3 and 4 databases and writes KDBX 4, with master passwords sourced from another provider rather than embedded in the URI.

OpenBao gets its own openbao:// identity and BAO_* configuration while sharing compatible KV, token, AppRole, and JWT mechanics with Vault. Both Vault and OpenBao can now exchange a JWT for a short-lived token, including an OIDC token minted automatically in GitHub Actions and Forgejo Actions with id-token: write.

Scaleway Secret Manager adds regional, project-aware cloud storage and read-only references to existing secrets and revisions.

systemd credentials is a read-only provider that resolves values from the current service’s $CREDENTIALS_DIRECTORY, including credentials used to bootstrap another provider.

Alongside 0.17, the new cachix/secretspec-action installs SecretSpec, resolves the selected profile, masks every value in the runner log, and adds the secrets to the environment of later job steps:

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: cachix/secretspec-action@main
with:
profile: production
scope: api
- run: ./deploy.sh

A missing required secret fails the action, so the same step also checks that the deployment environment is complete. See the GitHub Actions guide for provider selection and tokenless Vault or OpenBao authentication through the runner’s OIDC identity.

SecretSpec 0.17 also brings:

  • Bitwarden Secrets Manager — now uses the separately installed official bws CLI instead of linking its SDK.
  • Non-interactive setupsecretspec config global init --provider ... --profile ... configures defaults without prompts.
  • Windows packages — for the Python, Ruby, and PHP SDKs.
  • Clearer status output — plus more controlled concurrency and retry behavior for Vault and OpenBao.
Terminal window
cargo install secretspec

See the full changelog for every change in this release.

The next work brings more control to local secret access:

  • GUI confirmation dialogs — approve or deny a secret request in a native prompt instead of requiring a terminal interaction.
  • A Passbolt provider — bring Passbolt’s open-source, collaboration-focused credential manager behind the same SecretSpec interface for cloud and self-hosted teams.
  • A Bitwarden Password Manager provider — resolve regular Bitwarden vault items, separately from the Bitwarden Secrets Manager provider already available in SecretSpec.
  • A JVM SDK — work is underway to bring the shared SecretSpec resolver to Java, Kotlin, and other JVM languages.
  • A FactorSeal provider — we have started work on a new Linux provider built around mandatory TPM-backed storage and secure defaults. FactorSeal also provides an explicit API for credential expiry, which is crucial for the caching work in this release: local copies can carry a defined eviction deadline instead of living without a retention policy. Still in development.

Questions or feedback? Join us on Discord.

But I Use SOPS

Whenever I show someone SecretSpec, I often hear the same response:

But I use SOPS.

SOPS is good. It encrypts files so they can live in Git without exposing their plaintext values.

But SecretSpec solves a different problem: how applications declare, find, and consume secrets.

Once you have encrypted secrets.yaml, how does your Python service consume it? What about your Go worker or Node.js app?

You still need to decrypt the file, inject its values, select the right file for each environment, validate required keys, and repeat that integration for every language.

And if you release the project as open source, that choice does not stay yours. With SOPS baked into the setup, everyone who runs or contributes to the project must adopt SOPS and its key management, whatever secrets tooling they already use.

SecretSpec starts at the other end. The project declares what the application needs without storing any values:

secretspec.toml
[project]
name = "payments"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "Postgres connection string" }
STRIPE_API_KEY = { description = "Stripe secret key" }

The same secret can come from a developer’s system keyring or CI environment variables, while a more sensitive production environment resolves it from Vault. Applications use the same declaration through eight SDKs for Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, PHP, and C# without knowing the provider.

Encrypted files also make the key workflow a project-wide requirement. Adding a teammate means adding their key to .sops.yaml and re-encrypting every file; removing one means rekeying and rotating the affected secrets, since their key already saw the plaintext. SecretSpec leaves identity and access to the provider: onboarding to Vault or a cloud secrets manager is granting a role, and offboarding is revoking it.

SOPS may be enough today. As your team grows more sensitive to how secrets are handled, you may want Vault’s access policies and centralized audit trail. If applications know about SOPS, each one needs migrating. If they know only SecretSpec, you change the provider configuration; SDK calls and secret names stay the same.

The same resolver provides profiles, required-secret checks, per-secret provider routing and fallback, provider-native references, temporary files, and metadata-only audit logs. You build the integration once, not once per provider and language.

SOPS protects a file. SecretSpec gives applications a provider-independent interface. The selected provider remains responsible for storage, encryption, identity, access control, and availability.

I wrote a fuller SecretSpec comparison showing exactly where SecretSpec ends, where providers begin, and which responsibilities belong to each layer.

Because applications talk to an interface instead of a file, the interface can grow without touching them. Three open proposals point where it is heading:

  • Project security requirements would let a project declare the guarantees a provider must meet, such as encryption at rest or an audit trail, and reject providers that fall short.
  • Lease-aware refresh would let running applications follow key rotation and short-lived credentials instead of restarting for a new value.
  • The SOPS provider (0.17+) brings SOPS itself behind the same SDK interface, making your encrypted files one more place secrets can come from.

With that provider, perhaps “But I use SOPS” just needs two more words:

But I use SOPS with SecretSpec.

If encrypted files fit your workflow, keep using SOPS. Just recognize the boundary: encryption at rest is not an application secrets interface.

Secrets Don’t Belong in Config

Applications should not require passwords, API keys, or tokens in their configuration files.

Configuration describes behavior. It belongs in git, code review, bug reports, and developer machines.

A secret grants authority. It needs restricted access and independent rotation.

Putting both in one file couples different lifecycles and audiences. If rotating a password requires regenerating application configuration, the interface has coupled them too tightly.

We audited all 445 NixOS modules that handle a real secret in nixpkgs at commit 141f212, classifying each by where its secret value ends up.

Where the secret value ends upModulesShare
Merged into a config file at runtime11025%
Inlined into a config in /nix/store429%
Delivered as an environment variable16136%
Left in a dedicated file opened by the app5813%
Loaded through systemd credentials5312%
Passed as a command-line argument194%
Classification uncertain2

The interesting number is 110. A quarter of the modules retrieve a secret safely, then copy it into configuration because that is the only interface the application accepts.

These modules use envsubst, replace-secret, jq, yq, sed, or custom code to assemble a restricted file at startup. The result can be secure, but every module now owns application-specific, security-sensitive glue just to combine two inputs that should have remained separate.

This is not unique to NixOS. The same workaround appears as an entrypoint script, Helm template, init container, or CI interpolation step on other platforms.

As a side note, 42 modules can inline secrets into the world-readable /nix/store. That direct security problem is tracked in nixpkgs issue #24288. The 110 runtime mergers make the broader point: even when deployment authors avoid the leak, the missing separation still creates work.

Applications should accept secret values through a dedicated runtime channel, such as:

  • a password_file or token_file setting;
  • a systemd credential;
  • a narrowly scoped environment variable;
  • or an external secret provider.

These mechanisms are not equally safe: environment variables can be inherited, arguments can appear in process listings, and files still need correct permissions. What separation does guarantee is that the deployer no longer has to manufacture a second, secret-bearing version of the configuration.

The principle is simple; implementing it across environments is not. Local development might use a system keyring, CI environment variables, and production 1Password or Vault. Without a shared abstraction, each environment needs its own naming, lookup, validation, and injection glue.

Cachix historically stored its auth token and per-cache signing keys in ~/.config/cachix/cachix.dhall, alongside cache names and other configuration. It was convenient, but the file had to be treated as a secret even though much of it was ordinary configuration.

A typical file mixed them directly:

~/.config/cachix/cachix.dhall
{ authToken = "XXX-AUTH-TOKEN"
, binaryCaches =
[ { name = "mycache"
, secretKey = "XXX-SIGNING-KEY"
}
]
}

The cache name is configuration; the auth token and signing key are secrets. You could not share the cache configuration without also sharing credentials.

devenv 2.2 separates the token through SecretSpec. The project declares CACHIX_AUTH_TOKEN, devenv resolves it from the configured provider, and the value is passed to Cachix without being added to devenv’s configuration.

Cachix PR #737 brings the same boundary into the client through the SecretSpec Haskell SDK. It resolves CACHIX_AUTH_TOKEN and CACHIX_SIGNING_KEY from SecretSpec and can store them in the user’s chosen provider instead of cachix.dhall. Existing environment variables and config files remain higher-priority fallbacks for compatibility. The PR is still open.

That is the problem SecretSpec is designed to solve: configuration declares the requirement, while each environment chooses where the value lives.

SecretSpec applies that separation by making secretspec.toml a declaration of what an application needs, without storing the values:

secretspec.toml
[project]
name = "myapp"
[profiles.production]
DATABASE_URL = { description = "Postgres connection string" }
STRIPE_API_KEY = { description = "Stripe secret key" }

Providers decide where the values live. A developer can use the system keyring, CI can use environment variables, and production can use 1Password, Vault/OpenBao, or a cloud secret manager without changing the declaration.

An existing application can receive the resolved values at startup:

Terminal window
secretspec run -- ./myapp

Applications can also resolve them directly through the SecretSpec SDKs for Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, PHP, and C#, all sharing the same resolver so behavior stays consistent across languages.

Providers own where secret values come from. SDKs give applications an idiomatic way to consume them. Configuration remains a shareable declaration of what is required.

If you maintain an application, stop adding passwords and tokens to ordinary configuration schemas. Accept a file reference, credential, environment variable, or provider instead.

For NixOS, SecretSpec issue #65 tracks how an official integration could declare and resolve secrets without per-module substitution glue.

Consistent secret handling across developer machines, CI, and production used to require infrastructure that only dedicated platform teams could build. A project of any size should be able to separate secrets from configuration without building its own secrets platform first.

SecretSpec 0.16: Composed secrets, Infisical, and C# SDK

SecretSpec 0.16 ships:

  • Composed secrets — derive a read-only value, such as a connection string, from other secrets declared in the manifest.
  • Infisical — read and write secrets in Infisical Cloud or a self-hosted instance, with Universal Auth, access-token, and provider-credential authentication.
  • C# SDK — resolve the same manifests from .NET through the shared native resolver, distributed as the Cachix.SecretSpec NuGet package.

Applications often need a connection string while secret stores work better with its independently rotated parts. SecretSpec can now keep those parts separate and assemble the application-facing value when it resolves the manifest:

secretspec.toml
[profiles.default]
DB_USER = { description = "Database user" }
DB_PASSWORD = { description = "Database password" }
DB_HOST = { description = "Database host" }
DATABASE_URL = {
description = "PostgreSQL connection string",
composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/app"
}

DB_USER, DB_PASSWORD, and DB_HOST still come from their configured providers. DATABASE_URL is assembled in memory and behaves like any other resolved secret in the CLI and SDKs. Compositions are read-only, may build on other compositions, and are checked for missing references and cycles before resolution.

See Composed Secrets for optional values, escaping, profile inheritance, and validation rules.

The new infisical:// provider works with Infisical Cloud, its EU service, and self-hosted instances. Point SecretSpec at an Infisical project and authenticate with Universal Auth:

Terminal window
export INFISICAL_CLIENT_ID=...
export INFISICAL_CLIENT_SECRET=...
secretspec run \
--provider "infisical://app.infisical.com/7e2f1a4c-...?env=prod" \
-- npm start

Access tokens are also supported. Credentials can come from environment variables or SecretSpec’s provider credentials, allowing, for example, an Infisical machine identity to be kept in the system keyring:

secretspec.toml
[providers.infisical]
uri = "infisical://app.infisical.com/7e2f1a4c-..."
[providers.infisical.credentials]
client_id = "keyring"
client_secret = "keyring"

By default, the active SecretSpec profile also names the Infisical environment. A production profile therefore reads from the production environment, while ?env= can select a different one. The provider supports normal SecretSpec reads and writes, as well as references to existing Infisical secrets and versions.

See the Infisical provider guide for self-hosting, authentication, paths, references, and permissions.

The Cachix.SecretSpec NuGet package brings the shared SecretSpec resolver to .NET 8:

Terminal window
dotnet add package Cachix.SecretSpec
using Cachix.SecretSpec;
using var resolved = SecretSpec.Builder()
.WithProvider("keyring://")
.WithProfile("production")
.WithReason("boot web app")
.Load();
Console.WriteLine(resolved.Secrets["DATABASE_URL"].Get());
resolved.SetAsEnv();

It uses the same resolver as the CLI and other language SDKs, so profiles, providers, fallback chains, references, generators, audit reasons, and composed secrets work consistently in .NET. Native resolver builds are included in the NuGet package, with no separate SecretSpec CLI installation required.

See the C# SDK guide for supported platforms, ASP.NET Core integration, preflight reports, error handling, and typed access.

Terminal window
cargo install secretspec

All three additions are opt-in: existing manifests and provider configurations continue to work unchanged. Add composed when a value should be derived, select an infisical:// provider to use Infisical, or install Cachix.SecretSpec in a .NET application.

See the full changelog for every change in this release.

Questions or feedback? Join us on Discord.