DevOps Culture & Fundamentals

The Twelve-Factor App

18 min Lesson 9 of 28

The Twelve-Factor App

In 2012, engineers at Heroku distilled years of experience operating large-scale SaaS applications into a manifesto: the Twelve-Factor App. The methodology defines twelve practices that, when followed together, produce software that is portable, scalable, and operationally predictable. Every factor ultimately answers a single question: how do we make this service safe to run and easy to reason about at 3 a.m. during an incident?

This lesson walks through each factor, explains the production rationale behind it, and shows what compliance and non-compliance look like in real systems. The DORA metrics from Lesson 5 measure outcomes; twelve-factor is the engineering discipline that makes those outcomes achievable.

Factor I — Codebase: One Repo, Many Deploys

A twelve-factor app is tracked in a single version-control repository. That one codebase is deployed to multiple environments — development, staging, production — from the same commit. Multiple codebases means a distributed system, not an app. Shared libraries belong in a dependency manager, not checked into each service.

Failure mode: Teams that maintain a separate "prod branch" with hotfixes that never get merged back accumulate divergence. Within weeks no one knows what is actually running in production.

Factor II — Dependencies: Explicitly Declare and Isolate

Never rely on implicit system-wide packages. Declare all dependencies in a manifest (package.json, requirements.txt, go.mod, Gemfile) and use an isolation mechanism (venv, node_modules, Go modules, Bundler) so a fresh checkout plus one install command produces a complete, runnable environment.

# Python — explicit isolation python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt # Pin every transitive dependency pip freeze > requirements.txt # Node npm ci # uses package-lock.json exactly — never npm install in CI
Never use curl | bash install scripts for system tools inside your app's runtime. If your Dockerfile runs apt-get install curl and then curls an installer, you have an implicit, unversioned dependency that will silently break when the upstream installer changes.

Factor III — Config: Store in the Environment

Everything that varies between deploys — database URLs, API keys, feature flags, log levels — must live in environment variables, not in code or config files committed to the repo. A litmus test: could you open-source the codebase right now without exposing a secret? If no, config has leaked into code.

# .env file (local only, NEVER committed) DATABASE_URL=postgres://user:pass@localhost/myapp REDIS_URL=redis://localhost:6379 LOG_LEVEL=debug STRIPE_SECRET_KEY=sk_test_... # In a Kubernetes deployment — secrets injected as env vars apiVersion: v1 kind: Secret metadata: name: app-secrets type: Opaque stringData: DATABASE_URL: "postgres://user:pass@prod-db:5432/myapp" --- apiVersion: apps/v1 kind: Deployment spec: template: spec: containers: - name: api envFrom: - secretRef: name: app-secrets
Group config variables by their deploy differences, not by service. A good sign: you can spin up the exact same Docker image in staging vs. production with only environment variables changed. No image rebuilds, no config file swaps.

Factor IV — Backing Services: Treat as Attached Resources

Databases, queues, caches, SMTP servers — treat them all as attached resources accessed via a URL in the environment. Local MySQL and a hosted RDS instance are interchangeable from the app's perspective. Swapping from a local Redis to ElastiCache should require only an environment variable change, no code change.

Factor V — Build, Release, Run: Strict Separation

Transform a codebase into a running deployment through three separate, non-reversible stages:

  • Build: compile, fetch dependencies, produce an artifact (Docker image, JAR, binary).
  • Release: combine the build artifact with deploy-time config. Every release gets an immutable ID.
  • Run: launch processes from the release in the execution environment.

Code never changes at runtime. The "deploy then edit config on the server" pattern violates this factor and makes rollbacks impossible to reason about.

Build → Release → Run pipeline Build Compile + image Release Image + config (immutable ID) Run Processes start (from release) ↑ Deploy config injected here
The three non-reversible stages — code is frozen at Build; config is injected at Release; processes are launched at Run.

Factor VI — Processes: Execute as Stateless, Share-Nothing Processes

App processes are stateless and share nothing. Any data that must persist lives in a backing service — the database or cache. In-memory session state, local filesystem uploads, and in-process caches all violate this factor. When a load balancer routes the next request to a different process instance, all sticky state is lost.

Real-world implication: sticky sessions are an anti-pattern. File uploads written to /tmp on one pod will not be visible to another. Move sessions to Redis/DynamoDB and uploads to object storage (S3, GCS) from day one.

Factor VII — Port Binding: Export Services via Port Binding

The app is self-contained and exports HTTP (or any protocol) by binding to a port. It does not rely on a runtime injection (e.g., Apache mod_php). A Node app runs its own HTTP server; a Python app runs Gunicorn internally. This makes the app trivially composable: it becomes a backing service to any other app.

Factor VIII — Concurrency: Scale Out via the Process Model

Scale horizontally by running more processes, not by vertically inflating a single monolith. Divide work into named process types — web (serves HTTP), worker (processes queued jobs), clock (scheduled tasks). Kubernetes Deployment replicas implement this factor directly. Each process type scales independently.

Factor IX — Disposability: Fast Startup and Graceful Shutdown

Processes should start in seconds and shut down gracefully when they receive SIGTERM. A web process finishes its current request then exits. A worker process returns its current job to the queue before exiting. This enables rapid scaling, deployment, and recovery from failures.

# Node.js — graceful SIGTERM handler const server = app.listen(PORT); process.on('SIGTERM', () => { console.log('SIGTERM received, shutting down gracefully'); server.close(() => { console.log('HTTP server closed'); process.exit(0); }); }); # Kubernetes gives pods 30 s by default before SIGKILL # Tune with terminationGracePeriodSeconds: 60

Factor X — Dev/Prod Parity: Keep Environments as Similar as Possible

The gap between development and production is the root cause of "works on my machine" bugs. Three dimensions of parity: time (deploy often, minimize lag), personnel (developers who write code deploy and observe it), tools (same database engine, same cache, same queue in dev and prod). Using SQLite in dev and PostgreSQL in prod is a classic violation — subtle SQL dialect differences cause bugs that only appear in production.

Docker Compose makes tool parity trivial. Run the exact same Postgres, Redis, and Kafka images locally that you run in production. There is no good reason to use a different backing service engine between environments.

Factor XI — Logs: Treat Logs as Event Streams

An app never concerns itself with routing or storing its output stream. It simply writes to stdout, unbuffered. The execution environment captures the stream and routes it to its destination — log aggregators (Datadog, Splunk, Loki), long-term storage, or a terminal during development. Never open log files directly, never manage log rotation inside the app.

Factor XII — Admin Processes: Run Admin Tasks as One-Off Processes

Database migrations, one-time data fixes, console inspections — run them as one-off processes in the same environment as the regular app processes, using the same release artifact and config. They must be committed to the repo, not executed as ad-hoc SSH sessions.

# Kubernetes — run a one-off migration job kubectl run migrations \ --image=myapp:v1.42 \ --restart=Never \ --env-from=secret/app-secrets \ -- python manage.py migrate # Same image, same env, same config — the twelve-factor way # Do NOT ssh into a pod and run commands manually

Twelve-Factor in Practice: A Diagnostic Checklist

When onboarding a new service, engineers at scale run through these questions quickly:

  1. Can I clone and run the app with zero manual steps beyond docker compose up? (I, II)
  2. Can I deploy to staging and production with only an environment variable change? (III, IV)
  3. Is every release immutable and tagged? Can I roll back in one command? (V)
  4. Will the app survive any single process dying and being restarted on a different host? (VI, IX)
  5. Are logs going to stdout and being captured by the platform? (XI)
  6. Are admin tasks reproducible scripts in version control? (XII)
Twelve-factor compliance is not binary. Treat it as a health gradient. A new service should score 12/12 from day one — retrofitting stateful, config-coupled legacy code is an expensive, multi-month project. The DORA metrics will show the drag: lower deployment frequency and higher change failure rate correlate strongly with twelve-factor violations.