Twelve-factor configuration is usually taught as a rule about environment variables. The rule that actually prevents incidents is narrower: no application code should be able to name the environment it is running in. This guide covers the seven checks that hold that line, and what happens when one service in the estate quietly stops holding it.
Twelve-factor configuration is the practice of storing everything that varies between deploys, including credentials, endpoints, and per-deploy values, in the environment rather than in code or in committed config files. Its defining test, from factor three of the twelve-factor methodology, is whether the repository could be published as open source at any moment without exposing a single credential. What it protects is the ability to change a deploy without changing code, and the ability to know what a running process is actually configured with.
None of the failures below come from engineers who do not understand this. They come from small conveniences that accumulate: one environment file committed during onboarding, one sandbox default added to unblock a test, one debug log of the whole environment left in after an incident. Each is locally reasonable. The population is what breaks.
A deploy is a set of values supplied at runtime, and the application should have no way to discover which deploy it is. The common failure looks harmless in review: config/production.yml, config/staging.yml, appsettings.Production.json, all committed, all selected at boot by an environment identifier. Six months later there are eleven of these files, three contain real hostnames, and nobody can say what values a given pod is running with. Read every value that varies between deploys from the environment or an injected configuration object.
# config/production.yml is committed, and APP_ENV picks it
import yaml, os
cfg = yaml.safe_load(open(f"config/{os.environ['APP_ENV']}.yml"))
db_url = cfg["database"]["url"] # a real hostname, in the repo# pydantic-settings, Python
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
payments_endpoint: str
settings = Settings() # raises if either value is absentThe twelve-factor config litmus test still holds: the repository should be publishable as open source at any moment without compromising a single credential. The tradeoff is that you lose the one file that showed the whole shape of a deploy. Recover it with a committed .env.example listing every key with placeholder values, which doubles as the declaration surface for principle five.
The moment a conditional compares an environment identifier against a literal, configuration has become control flow. A check on NODE_ENV is not a configuration read. It is a branch that executes in exactly one place, which makes it the one branch your test suite never covers, and the failure surfaces at the worst possible time. Express the difference as a named value instead. Not "are we in production" but "is webhook signature verification required," so a reader can tell what changes without knowing the deployment topology.
// TypeScript const stripe = new Stripe( process.env.NODE_ENV === "production" ? liveKey : testKey ); if (process.env.NODE_ENV !== "production") verifyWebhooks = false;
// TypeScript const stripe = new Stripe(config.stripeSecretKey); const verifyWebhooks = config.webhookSignatureRequired;
This is also the honest version of dev/prod parity. Parity is not about matching infrastructure between environments. It is about the code having no way to tell the difference. If you cannot run production configuration against a local process and get identical behavior, there is an environment branch somewhere in the codebase.
Every required configuration value should be read, parsed, and validated exactly once, during startup, into a typed object, and a missing or malformed value should stop the process. The alternative is the failure mode every platform team has debugged: an environment read sitting inside a handler that only runs on the refund path, returning an empty string for four days until someone issues a refund. A scattered read is also a scattered inventory. If configuration is read at seventeen call sites, nobody can enumerate what the service consumes without grepping, which makes principle five impossible to satisfy.
// Go
type Config struct {
StripeWebhookSecret string `env:"STRIPE_WEBHOOK_SECRET,required"`
ListenPort int `env:"PORT,required"`
}
cfg, err := env.ParseAs[Config]()
if err != nil {
log.Fatalf("config: %v", err) // never serves traffic
}The listen port belongs in that struct too. A hardcoded listen call on port 3000 breaks port binding and makes the service undeployable on any platform that assigns ports dynamically. The same applies to anything read lazily at the point of use. Move it into the startup struct so the inventory of what this service consumes is one file long.
DIAGNOSTIC: start the service with an empty environment. If it boots successfully, list every value it silently accepted. Those are your production defaults, whether anyone chose them or not.
An inline default on a required value does not make a service resilient. It makes the service run with a value nobody chose, reviewed, or knows about. A getenv call that falls back to a local development database is not a safety net. It is a service that starts cleanly in production, connects to something that does not matter, and reports healthy while every write fails.
A default that is plausible enough to pass code review and wrong enough to route live traffic is the most dangerous line in the file. The sandbox endpoint is always the one that ships.
Distinguish two categories and treat them differently. Tuning values with a genuine safe default, such as connection pool size or a timeout ceiling, can carry one, declared in the typed configuration object where a reviewer can see it. Endpoints, credentials, connection strings, and anything identifying a backing service carry none. Absence stops startup.
In Java this looks like a getOrDefault on a payments endpoint. In Python it is a second argument to os.getenv. In JavaScript it is a nullish coalescing operator on process.env. All three read as defensive coding in review. All three trade a deployment failure that takes ten minutes to fix for a data integrity failure that takes a week.
The image plus its manifest should be a complete declaration of what a service requires to run. Three things routinely violate this. A dependency declared with an unbounded constraint, so two builds of the same commit resolve differently. A variable the code reads that appears in no ConfigMap, Helm values file, or task definition, and works only because it happens to exist in this cluster. And a migration script that lives in an engineer’s home directory and gets run by hand against a live instance.
# deployment.yaml declares one variable
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef: { name: db, key: url }
# the code also reads FEATURE_LEDGER_V2 and SEARCH_ENDPOINT.
# they exist in this cluster, and in no other one.# deployment.yaml
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef: { name: db, key: url }
- name: FEATURE_LEDGER_V2
value: "true"
- name: SEARCH_ENDPOINT
value: "http://search.internal:9200"
# package-lock.json committed, no wildcard constraints declaredCommit the lock file and bound every constraint, per explicit dependency declaration. Ship administrative work as one-off processes invoked from the same image as the service, so a migration runs against the same code, the same dependency versions, and the same configuration the service is running with. A migration executed by hand from a laptop is a second, undocumented deployment path. Diff a service manifest against everything the code reads: any name the code reads and the manifest does not declare is ambient state the service is quietly depending on.
A twelve-factor process treats its local filesystem as scratch space that can vanish without warning. Two patterns break this quietly. Session data, uploaded files, or a shared cache written to a local path, which works perfectly on one replica and starts producing intermittent 404s the moment you scale to two. And logging configured in application code to a rotating file handler, which puts the logs inside a container nobody will ever exec into again.
Write durable state to object storage, a database, or a distributed cache. Emit logs to standard output as an unbuffered event stream and let the platform route them. This is the same stateless process model that makes horizontal scaling and fast disposal work at all. If a process can be killed at any moment without losing anything, deploys and scaling stop being risk events.
Local disk is fine for genuinely ephemeral work, such as a temp file written and consumed inside a single request. The test is whether anything later reads it back. If a second request, a second replica, or a support engineer needs that file, it does not belong on the instance.
Every principle above is a per-repository decision, and every one of them is also a fleet property. It does not matter that thirty-eight services validate configuration at startup if the thirty-ninth reads credentials from a committed file. It does not matter that most teams keep secrets out of version control if one environment file with live values landed in a repository two years ago and is still sitting in the history, which is what CWE-798 has described for a decade and what the OWASP Secrets Management Cheat Sheet still opens on.
The same asymmetry applies to logging the environment. A single call that serializes the whole process environment at startup, added during an incident and never removed, ships the entire credential set to whatever aggregator holds your logs, and to everyone with read access to it. One line, one service, whole estate. Log individually selected non-secret keys instead: region, tier, port, build identifier.
Configuration shares this shape with authorization enforcement. Both are properties you only hold if every service holds them, and both degrade the moment a repository is created by someone who has not read the standard. This is why configuration discipline cannot live in a document. Written standards describe the average. Configuration risk is decided by the outlier, and the outlier is by definition the repository nobody is reviewing this quarter.
Every principle above is a code shape rather than a judgment call. A branch on an environment name, a coalescing default on a required value, a variable the manifest never declares, a file log appender configured in application code: each is a pattern a static analyzer plus an LLM can locate and explain. That is what makes configuration discipline enforceable rather than aspirational.
A branch on an environment name, a fallback default on a required configuration value, a new environment file carrying real values, or a log call that serializes the whole environment surfaces on the line that introduced it, with the rule and a suggested fix, while the author still has the context to act on it.
An on-demand scan across every repository establishes the day-one baseline, including the violations that predate the guideline. This is where the eleven committed environment files, the hardcoded ports, and the undeclared variables from services written three years ago come out.
Drift introduced outside any pull request: a new service scaffolded from an old template, a repository inherited through an acquisition, a dependency constraint loosened during a hotfix and never tightened back.
Twelve-factor configuration is the practice of storing everything that varies between deploys, including credentials, endpoints, and per-deploy values, in environment variables rather than in code or committed config files. It comes from factor three of the twelve-factor methodology. Its defining test is whether a codebase could be open sourced without exposing any credential.
Configuration drift is the gap that opens between what a service configuration is declared to be and what it actually is at runtime. It appears when values are set by hand on a running instance, when a variable is present in the environment but absent from the manifest, or when two services that should share a value diverge. Declaring every consumed variable in the deployment manifest is what makes drift detectable instead of invisible.
A .env file is safe as a local development convenience and unsafe as a deployment mechanism. Commit only a .env.example with placeholder values, list the real files in the version control ignore file, and supply production values through the platform secret store. The risk is not the format. It is that the file sits in the working tree of every developer, and one careless add puts it in the history permanently.
Yes, when the values do not vary between deploys and are not secret. Route tables, dependency injection wiring, feature taxonomies, and validation constants belong in code. Encrypted secrets committed through SOPS or sealed-secrets are also defensible, since the ciphertext is useless without a key held by the platform. What is never acceptable is a plaintext file of real values selected at runtime by environment name.
Pick three services and answer four questions for each. Does any file contain a comparison against an environment name literal? Does the service start successfully with an empty environment, and if so, which defaults did it silently accept? Does every variable the code reads appear in the deployment manifest? Is there anything in the git history matching an environment file pattern? Most teams find a failure in the first service they check.
A secret scanner catches roughly one of these seven principles, and only the part that looks like a credential. It will not flag a branch on an environment name, a sandbox endpoint default, a variable the manifest never declares, or a rotating file handler. Those are code shapes, not string patterns, and they need a check that reads the codebase against a stated guideline rather than against an entropy threshold. Doing that on every pull request and on a schedule, across every repository, is what a codebase governance platform is for.
You'll get this as a guideline MD file. Hand it straight to your own coding agent or CI setup. Or skip the DIY and let Pandorian enforce all thirteen authorization rules on every pull request and every scan.
Every rule in this guide is easy to agree with in a design review and easy to lose in the fourth service created this quarter. Pandorian turns them into checks that run on every pull request and across every repository you own.