Architecture

Twelve-Factor Configuration: Environment Management Without Environment Names

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.

10 min read · 8 Principles · Infrastructure-as-Code
Three-tiered lavender cube stack on a square base, forming a small pyramid.

TL;DR

  • A deploy is a set of values, not a place. Configuration is read from the environment, never from a repository file selected by environment name.
  • Environment names belong in deployment manifests. Code that branches on NODE_ENV or ASPNETCORE_ENVIRONMENT has turned configuration into untested control flow.
  • Configuration is parsed and validated once at startup into a typed object. A missing required value stops the process instead of surfacing three hours later in a request handler.
  • A fallback default on a required value is an undeclared production decision. Absence should fail startup, not resolve to something plausible.
  • The deployable unit declares everything it needs: a committed lock file, every consumed variable in the manifest, and admin tasks as entrypoints in the same image.
  • A stateless process writes nothing it later depends on. No local session files, no shared caches on disk, no file log appenders configured in application code.
  • Configuration posture across an estate is set by the worst repository, not the average one. One committed environment file decides the blast radius for everyone.

What Twelve-Factor Configuration Actually Protects

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.

1. A Deploy Is a Set of Values, Not a Place

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.

ANTI-PATTERN: the environment name selects a committed file
# 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
CORRECTED: values arrive from the environment, typed and required
# pydantic-settings, Python
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    payments_endpoint: str

settings = Settings()   # raises if either value is absent

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

2. Environment Names Belong in Manifests, Not in Code

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.

ANTI-PATTERN: behavior keyed on the name of the deploy
// TypeScript
const stripe = new Stripe(
  process.env.NODE_ENV === "production" ? liveKey : testKey
);

if (process.env.NODE_ENV !== "production") verifyWebhooks = false;
CORRECTED: behavior keyed on named configuration values
// 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.

3. Configuration Fails at Startup or It Fails in Production

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.

CORRECTED: one validated load at boot, injected downstream
// 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.

4. A Default Is an Undeclared Production Decision

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.

RULE OF THUMB
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.

5. The Deployable Unit Declares Everything It Needs

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.

ANTI-PATTERN: the code reads more than the manifest declares
# 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.
CORRECTED: every consumed variable declared, every version bounded
# 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 declared

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

6. A Stateless Process Writes Nothing It Later Depends 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.

7. Configuration Posture Is Set by the Worst Repository

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.

How Pandorian Enforces This Guide

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.

Caught at PR level

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.

Full scans

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.

Scheduled scans

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.

A Taste of the Guideline Package

RULE WHAT IT FLAGS
ARC_58230
A conditional comparing an environment identifier such as NODE_ENV or ASPNETCORE_ENVIRONMENT against a literal like production or staging in order to select behavior, endpoints, or credentials.
CQM_74519
An inline fallback default supplied when reading a required configuration value: a coalescing operator or a getenv default argument on a database URL, service endpoint, secret, or credential.
SEC_50176
A .env, .env.production, local settings, or equivalent environment file committed with real endpoints or credentials rather than placeholders, or missing from the version control ignore file.
ARC_29581
An environment variable read by application code that is not declared in the service deployment manifest, Helm values, task definition, or IaC configuration.

FAQ

What is twelve-factor configuration?

+

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.

What is configuration drift?

+

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.

Are .env files safe to use?

+

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.

Is it ever acceptable to commit configuration to the repository?

+

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.

How do I tell whether my services actually follow this?

+

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.

Can a linter or secret scanner catch this?

+

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.

Get the Guideline Package

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.

From Principles to Enforcement

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.