12-Factor App & Config Management
Why config lives in env vars. Validating at startup. Feature flags done right.
Why a methodology for this exists
In 2011, engineers at Heroku had watched a very large number of applications get deployed to their platform — and watched which ones deployed cleanly and which ones fought the process at every step. They wrote down the pattern they kept seeing in the well-behaved ones. That document is the Twelve-Factor App.
It is not a framework and there is nothing to install. It is a set of twelve principles for how an application should be built so that it is painless to deploy, scale, and operate. The principles are old enough now to feel obvious — which is the highest compliment a methodology can earn — and modern platforms like Kubernetes and Cloudflare Workers quietly assume your app already follows most of them.
The unifying idea behind all twelve is worth stating up front, because once you have it the individual factors are mostly corollaries: an application should be a stateless, self-contained unit that takes everything specific to its environment from the outside, and keeps nothing important on its own local disk or in its own memory. An app built that way can be started, stopped, copied, and moved without ceremony. An app that violates it has to be handled carefully, like something fragile.
This module walks the factors that matter most in day-to-day backend work, then goes deep on the one most people get wrong: configuration.
The factors that shape how you build
The twelve factors are not equally weighty in practice. These are the ones that change real architecture decisions.
Codebase — one codebase, many deploys. One repository tracked in version control. The same codebase is what runs in development, in staging, and in production — those are deploys of one codebase, not three codebases. Different code per environment is the factor being violated, and it is how "works in staging, breaks in prod" is born.
Dependencies — declared explicitly, never assumed. Every library your app needs is listed in a manifest (package.json, requirements.txt, go.mod, pom.xml) with versions pinned. The app must never rely on a tool just happening to be present on the host. The test of this factor: a new developer, or a fresh build server, can go from clean machine to running app with one documented install step.
Config — in the environment, not the code. Anything that differs between deploys — database URLs, API keys, hostnames — lives outside the code, in the environment. This is the big one; section three is entirely about it.
Backing services — attached resources, swappable by config. The database, the cache, the message queue, the mail sender — treat every one as an attached resource reached through a URL or credentials in config. Swapping your local Postgres for a managed cloud Postgres should be a config change and nothing else. The app code does not know or care whether a service is local or third-party.
Processes — stateless and share-nothing. This is the heart of the method. Your app process keeps no important state in its own memory or on its own local disk between requests. Anything that must persist goes to a backing service — a database, Redis, object storage. Why it matters so much: a stateless process can be killed and replaced freely, and you can run fifty identical copies behind a load balancer because no copy holds anything the others lack. The moment a process stashes a user's session in local memory, you can no longer freely scale or restart it — request two might land on a different copy that never saw request one.
Port binding — the app is self-contained. The app exports its service by binding to a port itself; it is not injected into an external web server at runtime. It is a complete, runnable thing on its own.
Concurrency — scale out by adding processes. Need more capacity? Run more identical process copies (scale out), rather than only making one process bigger (scale up). This is only possible because of the stateless-processes factor — the two are a pair.
Disposability — start fast, shut down gracefully. Processes should start quickly and, on SIGTERM, stop cleanly — finishing in-flight work before exiting. This is Module 17 in its entirety, and it is a twelve-factor requirement because frequent, fearless deploys depend on it.
Dev/prod parity — keep the environments alike. The gap between a developer's laptop and production should be small — same database engine, same versions, same backing services. A wide gap is a generator of bugs that only appear in one place.
Logs — treat logs as event streams. The app does not manage log files. It writes events to standard output and forgets about them. The execution environment captures, routes, and stores that stream. (DevOps Lesson 24 covers the receiving end.)
Config management — the factor most teams get wrong
The config factor deserves its own treatment because the idea is easy to repeat and the discipline is easy to break.
The litmus test for whether something is config: could this value be different in another deploy of the same code? A database URL differs between staging and production — config. An API key differs between your account and a colleague's — config. The number of retries an algorithm uses is the same everywhere — not config, that is just code.
And the rule the factor exists to enforce: a hardcoded value that should be config is a bug, even when the code runs correctly today.
# WRONG — config baked into code
db = connect("postgres://admin:hunter2@prod-db.internal:5432/app")
# RIGHT — code reads config from the environment
db = connect(os.environ["DATABASE_URL"])
The hardcoded version fails on three counts at once. It cannot run anywhere but production without an edit. It puts a password in your version control history — permanently, even if you delete the line later, because git remembers. And it makes "the same codebase, many deploys" impossible.
The canonical home for config is environment variables — key/value pairs handed to the process by whatever starts it. Every language reads them trivially (os.environ, process.env, System.getenv), every platform knows how to set them, and they are external to the code by construction.
For local development, a .env file holds the variables so each developer can have their own:
# .env — MUST be in .gitignore. Never committed.
DATABASE_URL=postgres://localhost:5432/app_dev
REDIS_URL=redis://localhost:6379
LOG_LEVEL=debug
Two practices make this robust rather than fragile.
Validate config at startup, not at first use. Read and check every required variable the moment the process boots. If DATABASE_URL is missing, the app should refuse to start, loudly, immediately — not boot "successfully" and then throw a confusing error twenty minutes later when the first request needs the database. Fail fast, at the start, where the cause is obvious.
Ship a committed .env.example. Commit a file listing every variable the app needs, with placeholder or safe default values and no real secrets. It documents the app's full config surface — a new developer copies it to .env and fills in the blanks, instead of discovering required variables one crash at a time.
Secrets are config — but config with sharper edges
A subset of config is secret: database passwords, third-party API keys, signing keys, encryption keys. Secrets follow every config rule — never in code, supplied by the environment — plus stricter handling, because a leaked secret is a security incident.
Secrets never enter version control. Ever. Not in code, not in a committed config file, not "temporarily." Git history is effectively permanent and widely copied; a secret committed once should be treated as compromised and rotated, even after the line is deleted. This is exactly why the real .env is git-ignored and only the secret-free .env.example is committed.
Environment variables are an acceptable home for secrets in small setups, but they have real limitations at scale: they are visible to anything that can inspect the process, they are not encrypted at rest, and they offer no audit trail of who read what. Past a certain size, teams move secrets into a dedicated secrets manager — HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, or a cloud platform's built-in secret store.
A secrets manager adds what plain environment variables cannot:
- Encryption at rest — secrets are stored encrypted, not as plaintext.
- Access control — this service may read this secret and no other.
- Audit logging — a record of which identity accessed which secret, and when.
- Rotation — a secret can be changed centrally on a schedule, without redeploying every app that uses it.
The decision is one of scale and risk, not fashion. A side project or an early-stage app with environment variables set through its hosting platform's dashboard is doing nothing wrong — that is proportionate. A system handling real user data, payment credentials, or anything under a compliance regime has outgrown plain environment variables and wants a real secrets manager. Match the mechanism to the stakes.
Common mistakes
Different code per environment. An if (environment === 'production') branch that changes real behaviour violates the codebase factor and means staging is no longer a faithful rehearsal of production. Environments should differ by config values, not by code paths.
Committing the real .env. The most common way secrets leak. The real .env belongs in .gitignore from the first commit; only .env.example is tracked. A secret that reaches git history is compromised — rotate it.
Reading config scattered through the codebase. process.env.WHATEVER appearing in thirty different files means no single place documents what the app needs, and a missing variable surfaces as a crash deep in some request. Read and validate all config once, at startup, in one module, and pass typed values onward.
Treating genuine code constants as config. Not everything variable is config. A value that is the same in every deploy — a fixed retry count, a mathematical constant, a default page size that never changes per environment — is just code. Pushing it into environment variables adds an external dependency and a way to misconfigure, for no benefit. Config is specifically for what differs between deploys.
Storing state in the process. Caching sessions, uploaded files, or job state in local memory or on the local disk breaks the stateless-processes factor and quietly forbids scaling out and restarting freely. Mutable state belongs in a backing service.
Following all twelve factors religiously where they do not fit. This matters enough for its own paragraph.
The twelve-factor method was written for a specific shape of application: a stateless, horizontally-scaled web service deployed to a cloud platform. For that shape — which is most backend services — it is excellent guidance and you should follow it closely. But it is guidance for a shape, not universal law. A stateful database server's entire job is to keep state on local disk — the stateless-processes factor simply does not apply to it, and pretending otherwise is nonsense. A desktop application, a batch job, an embedded system, a machine-learning training run with gigabytes of local working data — these have different constraints, and forcing twelve-factor orthodoxy onto them produces awkward designs in service of dogma.
Understand why each factor exists — almost always "so the app can be deployed, scaled, and replaced without ceremony" — and apply it where that goal is real. For a normal cloud-deployed backend service, that is nearly everywhere, and the method has aged extremely well. For software that is genuinely a different kind of thing, take the factors that transfer and let the rest go. Methodology serves the system; the system does not serve the methodology. Next module: DevOps for backend engineers — the deployment environment these well-behaved apps get deployed into.
The Backend from First Principles series is based on what I learnt from Sriniously's YouTube playlist — a thoughtful, framework-agnostic walk through backend engineering. If this material helped you, please go check the original out: youtube.com/@Sriniously. The notes here are my own restatement for revisiting later.