Some applications are a pleasure to operate: you can scale them by adding copies, restart them at any moment, and promote one build from staging to production by changing a few variables. Others fight you at every step. The difference is rarely the language or framework. It is a set of design habits, written down in 2011 as the Twelve-Factor App, which containers and Kubernetes now simply assume. This module teaches the factors that matter most in practice and how to check an app against them.
- Explain why one build must run unchanged in every environment, and how config in the environment makes that possible
- Design stateless, share-nothing processes that scale horizontally
- Make processes disposable: fast start-up, graceful shutdown on
SIGTERM, crash safety - Treat logs as event streams and backing services as attached resources
- Audit an existing application against the twelve factors and prioritise the fixes
The twelve factors at a glance
The methodology describes how to build software that is delivered as a service. Read it as a contract between the application and the platform: if the app keeps these promises, the platform can build, run, scale and replace it without special knowledge.
| # | Factor | In one sentence |
|---|---|---|
| 1 | Codebase | One repository per app, many deploys from it. |
| 2 | Dependencies | Declare every dependency explicitly and isolate it; assume nothing is installed on the host. |
| 3 | Config | Everything that varies between deploys lives in the environment, not in the code. |
| 4 | Backing services | Databases, queues and caches are attached resources, swappable by changing config. |
| 5 | Build, release, run | Strictly separate stages; a release is an immutable build plus config. |
| 6 | Processes | The app runs as stateless, share-nothing processes. |
| 7 | Port binding | The app is self-contained and exports its service by listening on a port. |
| 8 | Concurrency | Scale out by running more processes, not by making one bigger. |
| 9 | Disposability | Start fast, shut down gracefully, survive sudden death. |
| 10 | Dev/prod parity | Keep development, staging and production as similar as possible. |
| 11 | Logs | Write an event stream to stdout; let the platform route it. |
| 12 | Admin processes | Run one-off tasks such as migrations from the same release, in the same environment. |
You have already met several of these. Factor 2 is what a Dockerfile and a lockfile give you. Factor 5 is the "build once, promote everywhere" rule from the CI/CD module. Factor 7 is why a container simply listens on a port. The rest of this module concentrates on the four that cause the most operational pain when ignored: config, processes, disposability and logs.
Config belongs in the environment
Config is everything likely to differ between deploys: database URLs, credentials, hostnames of other services, feature switches. The litmus test is simple: could you make the repository public right now without leaking a credential? If not, config is living in the code.
The twelve-factor answer is environment variables. They are language-neutral, every platform can set them (docker run -e, Compose environment:, Kubernetes ConfigMaps and Secrets, systemd EnvironmentFile), and they are hard to commit by accident. The same image then runs anywhere; only the environment differs.
import os
import sys
def require(name: str) -> str:
value = os.environ.get(name)
if not value:
sys.exit(f"FATAL: required environment variable {name} is not set")
return value
DATABASE_URL = require("DATABASE_URL") # no default: must be provided
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") # safe default
PORT = int(os.environ.get("PORT", "8000"))
FEATURE_NEW_CHECKOUT = os.environ.get("FEATURE_NEW_CHECKOUT", "false") == "true"- Fail fast. Validate required config at start-up and exit with a clear message. A crash at boot is caught by the deploy; a
Nonethat surfaces on the first request at midnight is an incident. - No environment names in code.
if env == "production"multiplies with every new environment. Give each behaviour its own variable instead. - Secrets are config too, but they need more care than a plain variable: they come from a secret manager and are injected at runtime. The secrets module covers that.
- A local
.envfile is fine for development. It stays in.gitignore, and a committed.env.exampledocuments which variables exist.
Stateless processes and attached resources
A twelve-factor process keeps nothing important in its own memory or on its local disk between requests. Session data, uploaded files, job queues and caches that must be shared all live in a backing service: a database, an object store, Redis, a message queue. The process can use memory and disk as a scratchpad within a single request, and nothing more.
The payoff is that every copy of the app is identical and interchangeable. A load balancer can send any request to any instance; an orchestrator can kill one and start another; scaling is just changing a replica count. The moment an instance holds unique state, such as sessions in memory, users get logged out on every deploy and you are forced into sticky sessions, which undermine load balancing and failover.
| State | Wrong place | Right place |
|---|---|---|
| User sessions | Process memory | Redis, database, or a signed cookie |
| Uploaded files | Local disk of one instance | Object storage |
| Background jobs | An in-memory list | A queue or a database table |
| Scheduled tasks | A timer inside every web instance | One scheduler, or a platform cron job |
Backing services are attached resources: the app should not care whether PostgreSQL is a local container or a managed cloud database. Both are just a URL in config. If swapping one for the other needs a code change, factor 4 is broken.
Disposability: start fast, stop gracefully
On a modern platform, processes are started and stopped constantly: deploys, autoscaling, node maintenance, crashes. An app must treat that as normal. Fast start-up makes scaling and recovery quick. Graceful shutdown means that on SIGTERM the app stops accepting new work, finishes the requests in flight, releases its resources and exits, all within the platform's grace period.
import signal
import sys
import threading
shutting_down = threading.Event()
def handle_sigterm(signum, frame):
print("SIGTERM received, finishing current job", flush=True)
shutting_down.set()
signal.signal(signal.SIGTERM, handle_sigterm)
while not shutting_down.is_set():
job = fetch_next_job(timeout=5) # returns None when the queue is idle
if job is not None:
process(job) # finish it; do not abandon it halfway
acknowledge(job)
close_connections()
sys.exit(0)Web frameworks and servers such as gunicorn, uvicorn and most Node and Go HTTP servers can do this for you, provided the signal actually reaches them, which is why the Docker module insisted on exec-form CMD.
Graceful shutdown is the polite case. Hardware fails and processes get SIGKILL, so the app must also be crash-safe: a job is acknowledged only after it completes, so that a dead worker's job returns to the queue, and operations are idempotent so that running one twice does no harm.
A cheap test of disposability: in staging, kill a random instance during a load test. If users see errors or a job is lost, you have found the work to do before production finds it for you.
Logs are a stream, not a file
A twelve-factor app does not open log files, rotate them or ship them anywhere. It writes one event per line to stdout (and errors to stderr), unbuffered, and stops caring. The execution environment captures the stream and routes it: docker logs, journalctl, kubectl logs, or a collector that forwards everything to a central store. The app stays simple, and operators can change the log destination without touching it.
Make the stream machine-readable. Structured logging emits each event as JSON with consistent fields, so that you can filter by level, request_id or user_id instead of writing regular expressions against prose.
import json
import sys
import time
def log(level: str, message: str, **fields) -> None:
event = {"ts": time.time(), "level": level, "msg": message, **fields}
print(json.dumps(event), file=sys.stdout, flush=True)
log("info", "order created", order_id="o-1842", request_id="f3a9", duration_ms=41)
# {"ts": 1767225600.0, "level": "info", "msg": "order created", "order_id": "o-1842", ...}Never log secrets, tokens, passwords or full request bodies that may contain personal data. Logs are copied to many systems and kept for a long time; anything written there should be treated as widely readable.
Parity and admin tasks
Dev/prod parity means closing three gaps: the time gap (deploy hours after writing code, not weeks), the personnel gap (the people who write it are involved in running it), and the tools gap (the same backing services everywhere). SQLite in development and PostgreSQL in production is the classic trap: tests pass, then a query behaves differently in production. With Compose, running the real database locally costs a few lines.
Admin processes such as database migrations or one-off data fixes run as separate, short-lived processes using the same image and the same config as the app, never from a developer's laptop against production. That guarantees the task runs against the exact code and settings that are deployed.
# same image, same environment, different command
docker compose run --rm app python manage.py migrate
# the Kubernetes equivalent is a Job, or a one-off command in the running release
kubectl exec deploy/myapp -- python manage.py migrateThe twelve factors are a baseline, not the whole story. They predate containers and say little about health checks, metrics, tracing or security, which the rest of this site's tracks cover. But an app that satisfies them is one that Kubernetes, autoscalers and pipelines can handle without surprises.