Education › DevOps › Stage 2: Build & ship

Twelve-Factor applications

Config in the environment, stateless processes, disposability — apps that are easy to operate.

Beginner–Intermediate ~25 min read Module 7 of 17

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.

After this module you can
  • 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.

#FactorIn one sentence
1CodebaseOne repository per app, many deploys from it.
2DependenciesDeclare every dependency explicitly and isolate it; assume nothing is installed on the host.
3ConfigEverything that varies between deploys lives in the environment, not in the code.
4Backing servicesDatabases, queues and caches are attached resources, swappable by changing config.
5Build, release, runStrictly separate stages; a release is an immutable build plus config.
6ProcessesThe app runs as stateless, share-nothing processes.
7Port bindingThe app is self-contained and exports its service by listening on a port.
8ConcurrencyScale out by running more processes, not by making one bigger.
9DisposabilityStart fast, shut down gracefully, survive sudden death.
10Dev/prod parityKeep development, staging and production as similar as possible.
11LogsWrite an event stream to stdout; let the platform route it.
12Admin processesRun 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.

python
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 None that 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 .env file is fine for development. It stays in .gitignore, and a committed .env.example documents 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.

STATELESS PROCESSESBACKING SERVICESconfig at startany request, any replicaone JSON line per eventreads and writessessions, never in memoryfiles, never on local diskEnvironmentDATABASE_URL=...Load balancerLog collectorreads stdoutApp processreplica 1App processreplica 2App processreplica 3PostgreSQLrecordsRedissessions, cacheObject storageuploads
The shape of a twelve-factor deployment: identical stateless processes behind a load balancer, configured from the environment, holding all shared state in attached backing services, and writing logs to stdout for the platform to collect.
StateWrong placeRight place
User sessionsProcess memoryRedis, database, or a signed cookie
Uploaded filesLocal disk of one instanceObject storage
Background jobsAn in-memory listA queue or a database table
Scheduled tasksA timer inside every web instanceOne 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.

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

Tip

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.

python
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", ...}
Watch out

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.

bash
# 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 migrate

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

Hands-on practice

Audit and fix a real application

  1. Take the app from the Docker module, or any service you work on, and score it against all twelve factors: pass, partial or fail, with one line of evidence each.
  2. Search the code for hard-coded hosts, credentials and if environment == branches. Move each one to an environment variable, add start-up validation that exits with a clear message, and commit a .env.example.
  3. Find any state held in process memory or on local disk (sessions, uploads, caches). Move one of them to a backing service started with Compose.
  4. Run two instances of the app behind a simple load balancer or just on two ports. Log in through one, make a request to the other, and confirm the session still works.
  5. Add a SIGTERM handler or configure your server's graceful timeout. Start a slow request, run docker stop, and prove the request completes and the container exits before the ten-second kill.
  6. Switch logging to one JSON object per line on stdout and filter it: docker logs CONTAINER | jq 'select(.level == "error")'.
Cheat sheet

Twelve-Factor applications — at a glance

Main things to focus on

  • One immutable build runs in every environment. Only config differs, and config lives in environment variables.
  • Litmus test for config: could the repository be made public today without leaking anything?
  • Processes are stateless and share nothing. Shared state goes in a backing service.
  • Scale out with more processes, not a bigger one.
  • Disposability: fast start-up, graceful SIGTERM handling, crash-safe and idempotent work.
  • Logs are an unbuffered event stream on stdout, one structured event per line. The platform routes them.
  • Migrations and one-off tasks run from the same release and config as the app.

The twelve factors

1 CodebaseOne repo per app, many deploys
2 DependenciesExplicitly declared and isolated (lockfile, image)
3 ConfigIn the environment, never in code
4 Backing servicesAttached resources addressed by URL in config
5 Build, release, runRelease = immutable build + config
6 ProcessesStateless, share-nothing
7 Port bindingSelf-contained; serves by listening on a port
8 ConcurrencyScale out via the process model
9 DisposabilityFast start, graceful stop, crash-safe
10 Dev/prod paritySame services and tools everywhere

Factors 11-12 and config patterns

11 LogsEvent stream on stdout; never manage log files
12 Admin processesOne-off tasks run from the same release
os.environ["NAME"]Required variable: raises immediately if missing
os.environ.get("NAME", "default")Optional variable with a safe default
DATABASE_URL=postgres://user:pass@host:5432/dbA whole backing service as one URL
.env (ignored) + .env.example (committed)Local development config, documented but not leaked

Supplying config on each platform

docker run -e KEY=value IMAGESingle variable
docker run --env-file .env IMAGEVariables from a file
environment: / env_file: (Compose)Per-service variables in compose.yaml
envFrom: configMapRef / secretRef (Kubernetes)All keys of a ConfigMap or Secret as variables
EnvironmentFile=/etc/myapp/env (systemd)Variables for a unit

Shutdown sequence on SIGTERM

1. stop acceptingFail the readiness check or close the listener so no new requests arrive
2. drainFinish in-flight requests and the current job
3. releaseClose database connections, flush buffers, release locks
4. exit 0Before the grace period ends and SIGKILL arrives
ack after completionSo a killed worker's job is redelivered, not lost

Common pitfalls

  • Baking environment-specific config into the image, so each environment needs its own build.
  • Keeping sessions or uploads on a single instance, which breaks the moment you run two copies.
  • Branching on the environment name in code instead of giving each behaviour its own setting.
  • Writing logs to files inside the container, where they vanish with it and nobody collects them.
  • Ignoring SIGTERM, so every deploy drops the requests that were in flight.
  • Running migrations from a laptop against production with whatever code happens to be checked out.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →