Build apps that
scale, deploy & survive
The 12-Factor App is a famous checklist for building modern web apps and services that are easy to deploy, scale, and maintain in the cloud. This tutorial explains every factor from scratch — with plain-English analogies, Python examples, and diagrams.
What is the 12-Factor App?
In 2011, engineers at the cloud platform Heroku (led by Adam Wiggins) noticed the same problems appearing over and over in the hundreds of thousands of apps they hosted. So they wrote down twelve principles for building software that runs well as an online service. They called it The Twelve-Factor App.
A "factor" is just a rule of thumb. Each one targets a specific real-world headache — like "it works on my machine but breaks in production," or "scaling up means rewriting half the app." Follow the twelve factors and you get an app that is portable, scalable, and painless to deploy.
Why did this become necessary?
To see the point, picture how software used to be deployed. You'd order a physical server, wait weeks for it to arrive, and then your app was tied to that one machine for life. Need more capacity? Buy a bigger server. The machine dies? So does your app. The application and the hardware were glued together.
Cloud platforms changed everything: you can now spin up a new machine in minutes, run many copies of an app at once, and expect it to stay online while you patch or scale it. But that only works if the app is built to be independent of any specific machine. The twelve factors are the rules that make an app behave that way.
| Old way (tied to a server) | Cloud-native (12-factor) | |
|---|---|---|
| Getting capacity | Order hardware, wait weeks | Spin up a machine in minutes |
| Handling more load | Buy a bigger server (scale up) | Run more copies (scale out) |
| If a machine dies | App goes down with it | Another copy keeps serving |
| Moving providers | Painful migration | Runs the same on AWS, GCP, Azure… |
Who is this for?
Anyone building an application that runs as a service — a web API, a website backend, a microservice. You don't need to know Kubernetes or Docker yet. If you can write a small Python script, you can follow along.
The five promises of a 12-factor app
If you follow the twelve factors, your app should end up with these five qualities:
| Goal | What it means in plain English |
|---|---|
| Easy onboarding | A new developer can get the app running on their machine in minutes — by running a couple of setup commands, not by following a long, manual instruction page. |
| Portability | It runs the same way everywhere — your laptop, a teammate's machine, or the cloud — because it doesn't depend on anything special being pre-installed on the computer. |
| Cloud-ready | You can deploy it to modern cloud platforms without manually setting up and maintaining servers yourself — the platform handles that for you. |
| Continuous deployment | Your development setup and your live (production) setup are so alike that you can release new changes frequently and safely, instead of in big risky batches. |
| Scales effortlessly | When traffic grows, you handle it by running more copies of the app — not by rewriting how the app is built. |
The big picture
Before diving in, here's how the twelve factors group together. They're not random — they cluster around four concerns in the life of an app.
Codebase
The problem it solves
Imagine a team keeps two copies of their code in separate folders — app-prod for the live site and app-test for experiments — and edits each one by hand. Over time the two copies stop matching: a bug fixed in one is forgotten in the other, and soon nobody can say which copy is the "real," correct version. That confusion is exactly what this factor prevents.
The opposite mistake is just as bad: cramming several unrelated apps into one giant codebase, so changes to one app accidentally affect the others and no team clearly "owns" any part of it. The rule is simple: one app, one codebase — no copies, no crowding.
The rule
- One repo per app. Tracked in Git (or similar).
- Many deploys from it. Your laptop, staging, and production all run the same codebase at possibly different commits.
- Shared code between apps belongs in a library/package, not copy-paste.
In practice
This factor isn't Python code — it's how you use version control. You create the repository once, then every environment gets its own deploy from that same repo:
# Create the single codebase (done once)
$ git init
$ git add . && git commit -m "first version"
$ git remote add origin git@github.com:me/my-app.git
$ git push -u origin main
# Each environment is a DEPLOY of that same repo — never a copy.
# Production runs a specific, known commit (here a release tag):
$ git checkout v1.4.0 && deploy_to prod
# Staging can run the latest main while you test:
$ git checkout main && deploy_to staging
# "Which exact code is live?" always has an answer — the commit hash:
$ git rev-parse HEAD # e.g. 9f3c1a7… → that's what prod is running
Dependencies
requirements.txt or pyproject.toml). Never assume a library is "already installed" on the machine.The problem it solves
"It works on my machine!" Usually this means your machine happens to have a tool or library that the server doesn't. A 12-factor app removes that guesswork: dependencies are declared (written down) and isolated (your app can't accidentally use a system-wide version).
In Python
❌ Implicit / global
# No manifest. You just hope
# requests is installed globally.
import requests
# Works on your laptop, crashes
# on the server. 💥
✅ Declared & isolated
# requirements.txt — pinned versions
flask==3.0.3
requests==2.32.3
gunicorn==22.0.0
# isolated environment
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install -r requirements.txt
flask==3.0.3 (not just flask) means everyone — and every server — installs the exact same version. Modern tools like Poetry, pipenv, or uv with a lock file do this automatically and are widely used today.requirements.txt, pyproject.toml) and use an isolated environment (a virtualenv or a container).pip install into the system Python, or relying on a tool like curl/ImageMagick being present without declaring it.Config
The problem it solves
If your database password is hard-coded in app.py, then (a) it ends up in Git history for the world to see, and (b) you need a different copy of the code for every environment. Config is the stuff that differs between deploys; code is what stays the same.
The litmus test
In Python
❌ Config hard-coded
DB_URL = "postgres://admin:s3cret@" \
"prod-db.internal:5432/app"
DEBUG = True
# Secret in Git. One file per env. 😱
✅ Config from environment
import os
DB_URL = os.environ["DATABASE_URL"]
DEBUG = os.environ.get("DEBUG", "false") == "true"
# Same code everywhere; values
# injected per environment.
Locally, you keep values in a .env file (and add it to .gitignore!). A library like python-dotenv loads them:
# .env (NEVER commit this)
DATABASE_URL=postgres://localhost:5432/app_dev
DEBUG=true
# app.py
from dotenv import load_dotenv
load_dotenv() # reads .env into os.environ for local dev
config_prod.py and config_dev.py that get committed. They multiply, drift, and tempt people to commit secrets. The environment is the single, language-agnostic place for config.Backing services
The problem it solves
A "backing service" is any add-on your app consumes over the network: PostgreSQL, Redis, RabbitMQ, an SMTP server, an S3 bucket, a third-party API. A 12-factor app makes no distinction between a service running on your own laptop and one run by a third party. Both are just resources located by a URL in config.
In Python
The app reads each service's address from config (Factor 3) and connects to it. The code never hard-codes where the service lives:
import os
import psycopg # PostgreSQL driver
import redis
# The connection URL comes from the environment, not the code.
db = psycopg.connect(os.environ["DATABASE_URL"])
cache = redis.from_url(os.environ["REDIS_URL"])
Now switching services is purely a config change — no code edits, no redeploy of the code itself. You just point the same app at a different URL:
# Local development — services running on your own machine
DATABASE_URL=postgres://localhost:5432/app_dev
REDIS_URL=redis://localhost:6379
# Production — same code, now pointed at managed cloud services
DATABASE_URL=postgres://user:pass@db.cloud-provider.com:5432/app
REDIS_URL=rediss://user:pass@cache.cloud-provider.com:6380
os.environ["DATABASE_URL"]. Switching from a local Postgres to a managed cloud database should require zero code changes — only a new URL.Build, release, run
The three stages
| Stage | What happens |
|---|---|
| Build | Convert source code at a commit into an executable bundle: fetch dependencies, compile assets. Produces a build artifact. |
| Release | Combine the build with the current config (env vars). Each release gets a unique ID (e.g. a timestamp or version number) and is immutable. |
| Run | Launch the app's processes from a selected release in the execution environment. |
What the three stages look like
With Docker, the three stages map to commands you (or your CI pipeline) run in order:
# ── BUILD ── turn source code into one fixed artifact (an image),
# tagged with the exact commit so it's traceable.
$ docker build -t myapp:9f3c1a7 .
# ── RELEASE ── combine that artifact with this environment's config.
# The build never changes; only the config is attached.
$ docker run --env-file prod.env myapp:9f3c1a7 # release v42
# ── RUN ── the platform just launches the chosen release. Nothing
# is built or edited here — it only starts the processes.
# ── ROLLBACK ── a bad deploy? Don't patch it — just RUN an older,
# already-built release again. Instant and safe.
$ docker run --env-file prod.env myapp:e1b88d2 # previous release
Processes
The problem it solves
If a process stores your shopping cart in its own memory, then your next request — which might hit a different copy of the app — sees an empty cart. And if that process restarts, the data is gone. Statelessness means any process can handle any request.
A simple example: a visitor counter
Say your app counts page visits. The naive version keeps the count in a normal variable — in the process's memory:
visits = 0 # lives in THIS copy's memory only
def home():
global visits
visits += 1
return f"You are visitor #{visits}"
On your laptop, with one copy running, this looks fine. But in production you run several copies behind a load balancer (Factor 8). Now each copy has its own separate visits variable, so users get inconsistent numbers — and a restart resets that copy to zero:
The fix is to keep the count in a shared backing service — here Redis. Every copy reads and updates the same number, so they all agree and a restart loses nothing:
import os, redis
store = redis.from_url(os.environ["REDIS_URL"])
def home():
# INCR lives in Redis, shared by ALL copies.
visits = store.incr("visits")
return f"You are visitor #{visits}"
In Python
❌ State in memory
sessions = {} # in-process dict
def login(user):
# Lost on restart; invisible to
# other processes. 💥
sessions[user.id] = make_token()
✅ State in a backing service
import redis
store = redis.from_url(os.environ["REDIS_URL"])
def login(user):
# Shared, survives restarts,
# visible to all processes.
store.set(user.id, make_token())
Port binding
8000). Visitors reach your app by connecting to its address and that port number, like an apartment building (the computer) where each unit has its own door number (the port).The problem it solves
The old model required you to install your app inside a heavyweight web server (e.g. drop PHP files into Apache). A 12-factor app flips this: the app itself binds to a port and speaks HTTP. The platform routes public traffic to that port.
In Python
import os
from flask import Flask
app = Flask(__name__) # this IS the web server — no Apache needed
if __name__ == "__main__":
# Read the port from config; if it's not set, fall back to 8000.
port = int(os.environ.get("PORT", 8000))
# Start listening. host="0.0.0.0" means "accept visitors from
# anywhere", not just this one machine.
app.run(host="0.0.0.0", port=port)
For real traffic, the tiny built-in server isn't strong enough, so you run the same app behind a production server called Gunicorn. Notice it's still the exact same idea — bind the app to a port:
# "app:app" = the file app.py, and the variable named app inside it.
# $PORT is the port number the platform tells us to use.
$ gunicorn app:app --bind 0.0.0.0:$PORT
PORT environment variable. Platforms (Heroku, Cloud Run, Kubernetes) tell your app which port to use this way.Concurrency
The process formation
Different kinds of work become different process types. A common split: web processes handle HTTP requests, worker processes handle background jobs from a queue. You scale each type independently based on its load.
In practice
You declare your process types once — here in a Procfile, a simple list of "name: command to run":
# Procfile — defines the kinds of processes your app can run
web: gunicorn app:app --bind 0.0.0.0:$PORT # handles HTTP requests
worker: python worker.py # handles background jobs
Then you scale each type just by choosing how many copies to run — no code change at all:
# Normal load
$ scale web=2 worker=1
# Big sale today → add more copies of each type independently
$ scale web=5 worker=3
# (On Kubernetes the same idea is "replicas":)
$ kubectl scale deployment web --replicas=5
Disposability
Two qualities
- Fast startup: a process is ready in seconds, so you can scale up quickly and recover from crashes fast. Just be sure it's truly ready before it accepts traffic — database and network connections established — so the first users don't hit a half-booted app.
- Graceful shutdown: on a stop signal (
SIGTERM), the process stops taking new work, finishes what it's doing, and exits cleanly. Background jobs return unfinished work to the queue.
In Python
When a platform wants to stop your app (to redeploy, scale down, or move it), it doesn't kill it instantly — it first sends a polite "please wrap up now" message called SIGTERM. Your job is to listen for that message and shut down cleanly instead of dropping work mid-way:
import signal, sys
# This function runs when the platform asks the app to stop.
def shutdown(signum, frame):
print("Stop requested — finishing current work, then exiting")
# 1. Stop taking NEW requests, but let the ones already
# in progress finish (your own helper function).
drain_in_flight_requests()
# 2. Tidy up: close database connections, etc.
close_db_connections()
# 3. Exit calmly. Code 0 means "stopped on purpose, no error".
sys.exit(0)
# Tell Python: when SIGTERM arrives, run shutdown() above.
signal.signal(signal.SIGTERM, shutdown)
drain_in_flight_requests() and close_db_connections() are placeholders for your own cleanup steps — the key idea is the pattern: catch the stop signal, finish gracefully, then exit.
docker stop (or a deploy happens), it sends SIGTERM — "please wind down" — and waits a short grace period (Docker's default is 10 seconds). If your app finishes and exits in time, great. If it's still running after the grace period, the platform sends SIGKILL, which force-kills it instantly with no chance to clean up. So: handle SIGTERM and shut down quickly, or risk losing in-flight work.Dev/prod parity
The three gaps to close
| Gap | Traditional | 12-factor |
|---|---|---|
| Time | Weeks between writing and deploying code | Hours — deploy continuously |
| Personnel | Devs write, separate ops team deploys | The people who write code deploy it |
| Tools | SQLite locally, PostgreSQL in prod | Same backing services everywhere |
In practice
A docker-compose.yml file lets you run the real database and cache on your laptop with one command — the same versions you use in production, so there are no surprises:
# docker-compose.yml — your local stack mirrors production
services:
web:
build: .
environment:
DATABASE_URL: postgres://app@db:5432/app
REDIS_URL: redis://cache:6379
db:
image: postgres:16 # SAME version as prod — not SQLite
cache:
image: redis:7 # SAME version as prod
# Start the whole production-like stack locally:
$ docker compose up
Logs
The problem it solves
When apps write and rotate their own log files, you get a mess: logs scattered across machines, custom rotation code, and no easy way to search across many processes. It's even worse with containers — a log file written inside a container is destroyed when that container is thrown away (which happens constantly), taking your evidence with it. The 12-factor approach: the app does the simplest possible thing — write to stdout — and leaves the rest to the platform.
In Python
print(...) in your terminal. Writing logs there means "print them out and let whoever's listening decide what to do with them."❌ App writes its own log file
# App decides WHERE logs are stored
# and is responsible for managing
# that file forever.
logging.basicConfig(
filename="/var/log/app.log")
# Problems: a separate file on every
# machine, you must delete old logs
# yourself, and searching across all
# copies of the app is painful.
✅ App just prints to stdout
import logging, sys
# App only prints events to the screen
# (stdout). It does NOT manage files.
logging.basicConfig(
stream=sys.stdout,
level=logging.INFO)
# The platform captures this output and
# handles storing, searching & cleanup.
Modern tip: structured logs
Instead of printing plain sentences, print each log as a small JSON object on its own line (this is called a "structured log"). Machines can read JSON easily, so search tools can instantly filter by things like the user or the error type:
import json, sys
def log(**fields):
# Print one JSON object per line to stdout...
print(json.dumps(fields), file=sys.stdout, flush=True)
# flush=True = "unbuffered": send it out NOW, don't wait to
# collect a batch first. So events appear the instant they happen.
log(event="login", user_id=42, ok=True)
log(event="payment_failed", order_id=99, reason="card_declined")
Each call prints one line, e.g. {"event": "login", "user_id": 42, "ok": true}.
flush=True (shown above) or by setting the environment variable PYTHONUNBUFFERED=1.Admin processes
The problem it solves
Admin work (migrating a schema, running a cleanup script) often gets done with ad-hoc scripts on a random machine, against a different version of the code. That causes drift and "it ran fine in the script but broke the app" surprises.
In Python
# Run the migration using the SAME release + config as the app:
$ python manage.py migrate # Django
$ flask db upgrade # Flask-Migrate
# A one-off interactive shell against the live app's code/config:
$ python manage.py shell
# On a platform, run it as a one-off process in the prod environment:
$ heroku run python manage.py migrate
$ kubectl run migrate --image=myapp:v42 -- python manage.py migrate
Modern context: 12-factor in the age of containers
The methodology was written in 2011, before Docker and Kubernetes were mainstream. Remarkably, it has aged well — because today's tools implement these factors almost by default.
How today's tools map to the factors
| Modern tool | Which factors it enforces |
|---|---|
| Git / GitHub | 1 (one codebase, many deploys) |
| Docker image | 2 (declared deps), 5 (immutable build artifact), 7 (self-contained, port-binding), 10 (parity) |
| Env vars / secrets managers | 3 (config), 4 (backing services by URL) |
| Kubernetes / Cloud Run | 6 (stateless pods), 8 (replicas & autoscaling), 9 (fast start, SIGTERM), 11 (stdout collection) |
| CI/CD pipeline | 5 (build→release→run), 10 (continuous deployment) |
| K8s Jobs | 12 (one-off admin processes) |
Where people extend it today
The original list is from 2011. Since then, apps have grown into many small services talking to each other, so Kevin Hoffman's book Beyond the Twelve-Factor App proposed three more factors — API first, Telemetry, and Security — bringing the total to 15 (hence the name "15-factor app"; 12 + 3 = 15). Below are those three, plus health checks — not one of the official fifteen, but a closely-related practice you'll see everywhere. Treat them all as helpful add-ons; the original twelve are still the foundation.
Before writing any code, agree on the contract — exactly what requests a service accepts and what it sends back. Settling this first lets different teams build pieces that snap together cleanly later, instead of discovering mismatches at the end.
Logs (Factor 11) tell you what happened. Observability goes further: your app also reports numbers (e.g. requests per second, error rate) and traces (the path a single request took across services), so you can actually see how a busy system is behaving. A common tool is OpenTelemetry.
Treat "who is allowed to do what" as a built-in concern, not an afterthought. A key idea is least privilege: every user and service gets only the minimum access it truly needs — so a single leaked password can't unlock everything.
Your app exposes a simple "are you OK?" web address (often /healthz) that just replies "yes, I'm healthy." The platform pings it regularly; if a copy stops answering, the platform automatically restarts it or stops sending it traffic — keeping the service healthy on its own.
StatefulSets precisely for these. The lesson: 12-factor is an excellent default for stateless services, not a law of physics for every workload.One-page cheat sheet
Keep this handy. If you can answer "yes" to each, your app is 12-factor.
| # | Factor | The rule in one line |
|---|---|---|
| 1 | Codebase | One repo in version control, many deploys from it. |
| 2 | Dependencies | Declare every dependency explicitly; isolate them. |
| 3 | Config | Keep config (secrets, URLs) in environment variables. |
| 4 | Backing services | Treat DBs, queues, caches as swappable attached resources. |
| 5 | Build, release, run | Separate the three stages; releases are immutable. |
| 6 | Processes | Run stateless, share-nothing processes. |
| 7 | Port binding | Be self-contained; export the service on a port. |
| 8 | Concurrency | Scale out by running more processes. |
| 9 | Disposability | Start fast, shut down gracefully, survive being killed. |
| 10 | Dev/prod parity | Keep all environments as similar as possible. |
| 11 | Logs | Write events to stdout; let the platform handle them. |
| 12 | Admin processes | Run one-off tasks in the same release and environment. |
Glossary
Plain-English definitions of the technical words used in this tutorial. Skim it, or come back whenever a term trips you up.
- API
- "Application Programming Interface" — the agreed set of requests one program can make to another, and the responses it gets back. The contract between services.
- Artifact (build artifact)
- The packaged, ready-to-run output of the build stage — e.g. a Docker image or a zip. It's fixed: you run it, you don't edit it.
- Backing service
- Anything your app talks to over the network: a database, cache, message queue, email sender, file storage, or third-party API.
- Buffered / unbuffered
- Buffered output is held in memory and written in batches (efficient, but can be lost on a crash). Unbuffered output is written immediately, one line at a time.
- CI/CD
- "Continuous Integration / Continuous Deployment" — automated pipelines that build, test, and ship your code whenever you push a change.
- Codebase
- All of your project's source files, kept together in version control. One app has exactly one codebase.
- Commit
- A saved snapshot of your code in Git, with a unique ID (a hash). "Which commit is live?" pinpoints the exact code running.
- Config (configuration)
- The settings that change between environments — database URLs, API keys, passwords, feature flags. Kept out of the code, in environment variables.
- Connection string / URL
- A single line that tells your app where a backing service lives and how to log in, e.g.
postgres://user:pass@host:5432/db. - Container
- A lightweight, self-contained box that bundles your app with everything it needs to run, so it behaves the same everywhere. (Docker is the most common tool.)
- Dependency
- An external library or package your app needs to run (e.g. Flask, Redis client). Declared explicitly so every machine installs the same ones.
- Deploy (deployment)
- One running copy of your app in a particular place — your laptop, a staging server, or the live production site.
- Docker / Dockerfile / image
- Docker packages apps into containers. A Dockerfile is the recipe; running it produces an image (the artifact); running the image gives you a container.
- Environment (dev / staging / prod)
- A place your app runs. Dev = your machine; staging = a production-like test area; prod(uction) = the live site real users use.
- Environment variable
- A named value supplied by the surrounding environment (e.g.
DATABASE_URL) that your app reads at runtime. The standard place to store config. - Git / repository (repo)
- Git is the most common version-control tool; a repository is the project it tracks. GitHub/GitLab host repos online for teams.
- Graceful shutdown
- Stopping an app cleanly: refuse new work, finish what's in progress, close connections, then exit — instead of dying mid-task.
- Horizontal vs. vertical scaling
- Horizontal = run more copies of the app (scale "out"). Vertical = give one machine more CPU/RAM (scale "up"). 12-factor prefers horizontal.
- Idempotent
- Safe to run more than once with the same result. An idempotent task can be retried after a crash without causing harm (e.g. double charges).
- Immutable
- Can't be changed after it's created. A release is immutable: to change something you make a new release rather than editing the old one.
- JSON
- A simple, machine-readable text format for data, e.g.
{"user": 42, "ok": true}. Used for structured logs and APIs. - Kubernetes (K8s)
- A popular platform that runs, scales, and heals containerized apps automatically across many machines.
- Load balancer
- A traffic cop that spreads incoming requests across all your running app copies so no single one is overwhelmed.
- Log router / aggregator
- A tool (Fluentd, Logstash, etc.) that collects logs from all your processes and forwards them to a central searchable system (ELK, Splunk, Datadog…).
- Microservice
- A small, independent app that does one job and talks to others over the network, instead of one big "monolith" doing everything.
- Migration (database)
- A scripted change to your database's structure (adding a table or column). Run as an admin/one-off process.
- Port
- A numbered "door" on a machine where a program listens for network connections (e.g. a web app on port
8000). - Procfile
- A small file listing your app's process types and the command to start each, e.g.
web:andworker:. - Process
- One running instance of your program. A 12-factor app may run many identical processes at once.
- Redis
- A fast in-memory data store, often used as a shared cache or to hold session/state data that processes must share.
- Release
- A specific build combined with a specific config, given a unique version ID. What actually gets run — and rolled back to if needed.
- Rollback
- Returning to a previous, known-good release when a new deploy goes wrong — fast, because old releases are kept and immutable.
- SIGTERM / SIGKILL
- Stop signals an OS sends a process. SIGTERM = "please wind down" (you can handle it). SIGKILL = forced, immediate kill (no cleanup).
- State / stateless
- State is data remembered between requests. A stateless process keeps none of its own — it stores everything important in a backing service.
- stdout (standard output)
- The default place a program prints text — what you see in a terminal from
print(). 12-factor apps send logs here. - Structured logs
- Logs written as machine-readable data (usually one JSON object per line) so tools can search and filter them precisely.
- Version control
- A system (like Git) that tracks every change to your code over time, lets multiple people collaborate, and lets you go back to any past version.
- Virtual environment (venv)
- An isolated Python setup for one project, so its dependencies don't clash with other projects or the system Python.
- Web server (e.g. Gunicorn)
- The component that listens on a port and turns incoming HTTP requests into calls to your app. Gunicorn is a common production server for Python.
- Worker
- A process type that handles background jobs (sending emails, processing uploads) pulled from a queue — separate from
webprocesses that answer users.