$ emrebener

03. Config

author: emre bener read time: 9 min about: twelve-factor app, configuration, environment variable
published: updated: mentions: hashicorp vault, kubernetes, docker, asp.net core, feature toggle

1. The rule: store config in the environment

The factor is one sentence: store config in the environment. Config is everything that varies between deploys. The code stays the same across every deploy of an app; config is what differs from one to the next. Keep the two apart, config in the environment and code in the repo, and one codebase can run unchanged everywhere, from a developer’s laptop to production.

This is the half Codebase set up and deferred. Factor 1 said one codebase produces many deploys, and that two things vary across those deploys: where each deploy sits along the codebase’s history, and its config. The history half is version control’s job. The config half is this factor. Production talks to the production database; your laptop talks to a local one; same code, different config, and Factor 3 is the rule that keeps that difference out of the code.

As the series introduction put it, twelve-factor leans toward operability, and config is operability at its plainest. The app hard-codes nothing about which database or which credentials it will use; it reads all of that from the environment it happens to be running in.

2. What counts as config, and what doesn’t

Config is the set of values that change from one deploy to the next, and that single rule settles most cases. Three kinds of thing qualify:

  • Resource handles to backing services. The URLs and connection strings for the database, the cache, the message queue.
  • Credentials to external services. The keys and tokens for third-party APIs, the username and password on a backing service.
  • Per-deploy values. Things like the canonical hostname a given deploy serves under.

What is not config is the larger, quieter category: internal application config that doesn’t vary between deploys. How the app wires its own modules together, its routing table, its dependency-injection graph, which serializer a component uses. That’s part of the app’s definition, identical in every deploy, and it belongs in the code. The line isn’t “is it a setting”; plenty of settings live in code. The line is “does it vary between deploys.” If two deploys of the same app would set it differently, it’s config. If every deploy sets it identically, it’s code, no matter that it happens to live in a file with “config” in the name.

That’s a different axis from the one Dependencies runs on. Factor 2 is about what the app is made of, declared in a manifest and isolated at runtime; Factor 3 is about what varies per deploy, injected from outside. A dependency is the same version on your laptop and in production so it isn’t config; the database URL differs between them, so it is. Declared-and-isolated and injected-per-deploy are orthogonal properties, and a well-behaved app needs both.

3. The litmus test, and the anti-patterns it catches

There’s a one-question audit for this factor: could you open-source the codebase this minute, publicly, without exposing a single credential? If yes, your config is properly externalized. If publishing the repo would leak a database password or an API key, config is still tangled into the code, and the factor isn’t met. The test is sharp because it targets the thing that actually hurts: credentials sitting in version control, where they’re copied into every clone and preserved in history forever.

The test catches two anti-patterns. The first is constants in code: a hostname, a key, a connection string written as a literal somewhere in a source file. It fails the audit outright, and it breaks the per-deploy rule too, since changing it means editing and redeploying the code.

The second is subtler and far more common: config files committed to the repo. Even per-environment ones. A team that keeps a config/development.yml next to a config/production.yml, both checked in, has externalized config out of the code but not out of the repo, and the litmus test still fails, because the production file holds production’s secrets. Those files also have a way of multiplying. One per environment becomes one per environment per region, and the more of them there are, the likelier it is that someone eventually pastes a live credential into one and commits it. The failure is boring and total: the secret is now in git history, and rotating it is the only real fix.

4. The twelve-factor mechanism: environment variables

Twelve-factor’s answer is environment variables. Config lives in env vars, and the app reads them at startup. Three properties make them the fit:

  • Easy to change per deploy without touching code. Set DATABASE_URL=<connection-string> differently on each host and the same binary picks up each value; no edit, no rebuild.
  • No accidental commit. Env vars aren’t in the source tree, so there’s nothing to check in by mistake. The litmus test from the last section is satisfied by construction.
  • Language- and OS-agnostic. Every language can read an environment variable and every operating system can set one. Unlike a config format tied to one framework, env vars are a standard the whole stack already speaks.

The methodology then adds a sharper, more contested claim: config should be granular and orthogonal, not grouped into named “environments.” What it argues against is a named config group baked into the code or repo, a development bundle, a staging bundle, one for production, each a fixed set of values the app selects by name. The objection is that those named groups don’t scale. Every deploy that doesn’t fit an existing bundle, a second staging box, a throwaway environment for a demo, forces either a new named group or an awkward reuse of an old one, and the list of names grows without bound.

Env vars sidestep the problem: each deploy sets its own variables independently, with no shared name to belong to, so a new deploy is just a new set of values rather than a new entry in a hard-coded list.

Read faithfully, this isn’t an argument against having a staging environment. You’ll have dev, staging and production, and that’s fine. The objection is specifically to freezing their config into named groups inside the codebase, instead of injecting each deploy’s values per deploy. The target is the baked-in bundle, not the environment itself. ASP.NET Core is a concrete instance of where that line falls in practice: managing environments in ASP.NET Core reads its environment selector from an env var (ASPNETCORE_ENVIRONMENT), then loads per-environment files by name, the named-environment model this section is wary of, while keeping real secrets out of those files.

Named bundles in the repodevelopmentstagingproductionstaging-2demo+ one more per new deploy…The named list grows without bound —one entry per deploy that doesn't fit.Env vars, per deploydeploy ADATABASE_URL=<...>LOG_LEVEL=debugdeploy BDATABASE_URL=<...>LOG_LEVEL=infodeploy CDATABASE_URL=<...>LOG_LEVEL=warnEach deploy sets its own values —no shared name to belong to.Named bundles in the repodevelopmentstagingproductionstaging-2demo+ one more per new deploy…The named list grows without bound —one entry per deploy that doesn't fit.Env vars, per deploydeploy ADATABASE_URL=<...>LOG_LEVEL=debugdeploy BDATABASE_URL=<...>LOG_LEVEL=infodeploy CDATABASE_URL=<...>LOG_LEVEL=warnEach deploy sets its own values —no shared name to belong to.

5. Beyond env vars: secrets and how config gets injected

Environment variables are the right default for config, but they’re a weak place to keep secrets, and this is where the 2011 rule shows its age. “Put all config in env vars” bundles two different things and treats them the same: a plain per-deploy handle like a hostname, and a sensitive credential like a database password. For the handle, an env var is fine. For the secret, an env var leaks in more ways than people expect.

The leak vectors are all ordinary behavior of the tools around the app:

  • Child processes inherit the environment. Anything the app shells out to, a subprocess, a script, a helper binary, receives the whole set of variables by default, secrets included.
  • The environment is readable in the clear. A process’s environment is exposed by system introspection; on Linux it sits in a file under /proc, plaintext, readable by the process’s own user and root.
  • Crash and error dumps capture it. Error reporters and crash handlers routinely attach the process environment to a report, so a secret in an env var can travel to whatever third-party service ingests your stack traces.
  • The runtime or orchestrator surfaces it. A kubectl describe pod or a docker inspect prints the env vars set on a container; anyone with read access to the runtime or orchestrator can read them.
  • Logging frameworks spill it. Plenty of frameworks can dump the environment on startup or on error, and a well-meaning debug log turns into a plaintext secret store.

None of these is exotic. Every one of them treats an env var as ordinary, non-secret data, because that is exactly what an env var is.

one secret,in an env varan env var is ordinary,non-secret datainherited bychild processesreadable in the clearin /procattached to crash& error dumpsprinted by kubectl describe/ docker inspectspilled bylogging frameworksone secret,in an env varan env var is ordinary,non-secret datainherited bychild processesreadable in the clearin /procattached to crash& error dumpsprinted by kubectl describe/ docker inspectspilled bylogging frameworks

So modern practice splits config three ways, matching the mechanism to what’s being stored:

  • Simple, non-sensitive per-deploy config stays in environment variables: hostnames, deploy-time toggles, resource handles with no embedded secret. The 2011 mechanism is exactly right here.
  • Actual secrets move to a secret manager: HashiCorp Vault, or a cloud provider’s secret manager. The app fetches them at startup over an authenticated channel, or the orchestrator injects them as a mounted file rather than an env var. Kubernetes supports both, a Secret exposed as an env var or mounted into the container as a file, and some security guidance prefers the mounted file precisely because it dodges most of the vectors above: a file isn’t inherited by child processes and doesn’t show up in docker inspect.
  • Dynamic config that must change without a redeploy belongs in a config or feature-flag service the app reads at runtime, the one case “everything in env vars” can’t cover at all, since an env var is fixed for the life of the process.

None of this breaks Factor 3; it refines the mechanism while keeping the principle. The load-bearing idea was never env vars specifically. It was that config lives outside the code and is injected per deploy, and an env var, a mounted secret file, and a secret-manager fetch all satisfy that, differing only in how much they leak and how dynamically they update.

So the rule stays simple: reach for an env var for a plain handle, and a file or a secret manager for a real secret. You don’t need any of it to be twelve-factor. It’s just where the factor goes once secrets are on the line.

6. The payoff: one build, every deploy

All of this buys a single build artifact with no per-deploy values compiled into it, the property Build, release, run formalizes. Factor 5 splits the path from code to running process into three stages and places config at the last one: the build turns code into an artifact with no config baked in, and config is combined with that artifact only at the run stage. Keep config in the environment and that separation comes for free; bake config into the code and you get a different artifact per environment, which is a different app per environment wearing the same name.

That’s the whole factor. Config is what varies between deploys, it lives outside the code and is injected per deploy, and the reward is a single build you can carry through every environment and trust to be the same app every time.