Cloud-native fundamentals

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.

⏱ ~30 min read 🐍 Python examples 🎯 No prior cloud experience needed 📐 Diagrams included

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 capacityOrder hardware, wait weeksSpin up a machine in minutes
Handling more loadBuy a bigger server (scale up)Run more copies (scale out)
If a machine diesApp goes down with itAnother copy keeps serving
Moving providersPainful migrationRuns the same on AWS, GCP, Azure…
📦 Quick analogy Think of the 12 factors like the standard shipping container. Before containers, loading a ship was chaos — every crate was a different shape, and movers had to know what was inside to handle it. Then the world agreed on one standard box: now any crane, truck, train, or ship can move any container without caring what's inside. The 12 factors do the same for software: build your app to this standard, and any platform — your laptop, a cloud service, a teammate's machine — can pick it up and run it without special handling. (Fittingly, this is exactly the idea behind Docker containers, which you'll meet later.)

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.

🌱 Why learn this early? On most teams, senior developers already insist on these practices — "put that in an env variable," "don't store state in the process." If you don't know why, the rules feel arbitrary. Learning the twelve factors up front means you understand the reasoning behind those decisions from day one, instead of picking it up slowly through trial and error.

The five promises of a 12-factor app

If you follow the twelve factors, your app should end up with these five qualities:

GoalWhat it means in plain English
Easy onboardingA 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.
PortabilityIt 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-readyYou can deploy it to modern cloud platforms without manually setting up and maintaining servers yourself — the platform handles that for you.
Continuous deploymentYour 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 effortlesslyWhen 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.

📦 Code & Setup ⚙️ Config & Deps 🏃 Run & Scale 🔭 Operate 1 · Codebase 2 · Dependencies 5 · Build/Release/Run 10 · Dev/Prod parity 3 · Config 4 · Backing services 6 · Processes 7 · Port binding 8 · Concurrency 9 · Disposability 11 · Logs 12 · Admin processes All twelve work together → a portable, scalable, disposable service
The twelve factors grouped by the concern they address.
How to read each factor below Every factor has the same layout: a one-line rule, a real-world analogy, the problem it solves, a "bad vs. good" Python example, and a short "why it matters." Read top to bottom, or jump around using the sidebar.
1

Codebase

Keep all your code in one version-controlled place, and run many copies (deploys) from it
One app = one codebase (all your project's files, stored in a version-control tool like Git). From that single codebase you create many deploys — separate running copies of the app, e.g. one for development, one for staging, one for production.
📖 Two words you'll see a lot A codebase is simply your project's collection of files, kept in a version-control system (usually Git) that tracks every change. A deploy is one running copy of that app in a particular place — your laptop, a test server, or the live site customers use.
🍪 Analogy Think of one cookie recipe (your codebase) and the different batches you bake from it (your deploys). The recipe is identical every time — what changes is the batch: one you bake at home to taste-test (development), one for a friend to try (staging), and one you sell in the shop (production). Same recipe, many batches.

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.
Git repo one codebase 💻 Dev deploy 🧪 Staging deploy 🚀 Prod deploy
One codebase → many deploys. Each deploy may sit at a different commit, but it's the same repo.

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
✅ Do One Git repository, with environments as separate deploys (branches/tags/configs), not separate copies of the code.
⚠️ Avoid Two traps: (1) making a separate copy of the codebase for each environment — e.g. one repo for testing and another for production — instead of running them all from the same one; and (2) the reverse, stuffing several unrelated apps into a single codebase, so they get built and deployed together and can't be changed independently.
💡
Why it matters: A single source of truth means everyone deploys the same, traceable code. Bugs are reproducible, and "which version is live?" always has a clear answer (a commit hash).
2

Dependencies

Explicitly declare and isolate dependencies
Write down every library your app needs in a dedicated list file (for Python, that's requirements.txt or pyproject.toml). Never assume a library is "already installed" on the machine.
🧳 Analogy Packing a complete suitcase for a trip instead of hoping the hotel has a toothbrush, charger, and umbrella. You bring (declare) everything you need, so you're fine wherever you land.

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
📌 Note: pin your versions Writing 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.
✅ Do Keep a manifest (requirements.txt, pyproject.toml) and use an isolated environment (a virtualenv or a container).
⚠️ Avoid pip install into the system Python, or relying on a tool like curl/ImageMagick being present without declaring it.
💡
Why it matters: A new teammate runs two commands and has an identical setup. The same manifest makes builds reproducible from laptop to production.
3

Config

Store config in the environment
Anything that changes between environments — database URLs, API keys, credentials — lives in environment variables, not in your code.
🔌 Analogy A laptop charger works in any country once you snap on the right plug adapter. The laptop (your code) never changes — only the adapter (the environment config) does.

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

🧪 Quick check Could you open-source your codebase right now without leaking any secret? If yes, your config is properly externalized. If not, you have secrets in the code.

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
one build of your code (identical files everywhere) + different environment variables ↓ DEV config DATABASE_URL=…localhost DEBUG=true 💻 Dev deploy STAGING config DATABASE_URL=…stage-db DEBUG=false 🧪 Staging deploy PROD config DATABASE_URL=…cloud-db DEBUG=false 🚀 Prod deploy
Same code, three sets of environment variables → three different running deploys. The code never changes; only the config does.
⚠️ Avoid "config files per environment" like 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.
💡
Why it matters: Secrets stay out of source control, and the same build can be promoted from staging to production just by swapping environment variables. This is the backbone of safe, repeatable deploys.
4

Backing services

Treat backing services as attached resources
Databases, queues, caches, email senders — anything your app talks to over a network — should be swappable by just changing a URL in config.
💡 Analogy Your app treats a database like a lamp treats a wall socket. The lamp doesn't care which power plant generates the electricity — it just plugs in. Swap the socket (the connection URL) and it keeps working.

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.

Your app 🐘 PostgreSQL ⚡ Redis cache ✉️ SMTP / email ☁️ S3 storage attached via URL in config — swap freely
Every backing service is an attached, replaceable resource located by a connection string.

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
✅ Do Connect using a URL from config: os.environ["DATABASE_URL"]. Switching from a local Postgres to a managed cloud database should require zero code changes — only a new URL.
⚠️ Avoid Hard-coding hostnames, or writing code that only works with one specific provider's quirks.
💡
Why it matters: If a managed database goes bad, you swap to a fresh one by changing the URL — no redeploy of code. This loose coupling is what makes apps resilient and cloud-portable.
5

Build, release, run

Strictly separate build and run stages
Turning code into a running app happens in three distinct, one-way stages: BuildReleaseRun.
🏭 Analogy A factory: Build manufactures the product, Release boxes it with a label (this batch + its settings), and Run ships it to customers. You can't change the product after it's boxed — you make a new box.

The three stages

StageWhat happens
BuildConvert source code at a commit into an executable bundle: fetch dependencies, compile assets. Produces a build artifact.
ReleaseCombine the build with the current config (env vars). Each release gets a unique ID (e.g. a timestamp or version number) and is immutable.
RunLaunch the app's processes from a selected release in the execution environment.
BUILD code + deps → artifact RELEASE artifact + config = vN RUN execute processes One direction only · releases are immutable · roll back = run an older release
Build, release, run are separate and flow one way. You never edit code on a live server.

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
🧪 The key test In a 12-factor app it should be impossible to change code in the run stage — there's no way to feed a live edit back into the build. If you ever find yourself editing code on a running server, this separation has broken down.
✅ Do Give every release a unique ID. To fix a bad deploy, just run a previous release — instant rollback.
⚠️ Avoid Logging into the live server to quickly edit a file by hand. That fix only exists on that one server — it isn't part of any build, so it disappears the moment you deploy again, and the bug comes back. Instead, change the code, rebuild, and release. The run stage should do one simple thing: start the app, nothing more.
💡
Why it matters: Immutable releases make deploys predictable and rollbacks trivial. This separation is exactly what CI/CD pipelines and container images implement today.
6

Processes

Run the app as one or more stateless processes
A running copy of your app shouldn't remember anything between requests on its own. Anything that must be saved — user accounts, orders, sessions — goes into a shared backing service like a database, not into that copy's memory or its local files.
📖 Two words first A process is just one running copy of your app (Factor 8 runs several at once). Stateless means each copy keeps no important data of its own — so if it's restarted or replaced, nothing is lost, because the real data lives safely in the database.
🛎️ Analogy A hotel with interchangeable receptionists. You can speak to any one of them and they look up your booking in the shared computer system. They don't keep your details in their head — so if one goes on break, another helps you seamlessly.

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:

❌ Counter in memory → every copy disagrees copy 1 visits = 14 copy 2 visits = 7 copy 3 visits = 21 Same user refreshing the page sees 14, then 7, then 21 — depending on which copy answers. And restarting any copy wipes its count back to 0.
State kept inside a process can't be shared or trusted once there's more than one copy.

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())
users 👩🧑👨 app copy 1 (no data) app copy 2 (no data) app copy 3 (no data) 🗄️ shared database the ONE place state lives Any user can be served by any copy, because the real data is in the shared database — not in the copy.
Stateless copies hold no important data of their own; everything that must persist lives in a shared backing service.
📌 Note: "sticky sessions" are a crutch Some setups pin a user to one server so in-memory state works. 12-factor advises against it — store session data in Redis or a database instead, so any process is interchangeable.
💡
Why it matters: Statelessness is the secret that makes the next two factors possible. If processes hold no local state, you can add, remove, or restart them freely — which is exactly how you scale and recover.
7

Port binding

Make the app self-contained and reachable through a port
Your app brings its own built-in web server and makes itself reachable at a numbered "door" called a port. It doesn't need to be placed inside a separate, pre-installed web server (like Apache) to work.
📖 What's a "port"? A computer can run many programs at once, so each networked program listens at its own numbered slot — a port (for example, 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).
🚚 Analogy Think of a food truck. It carries its own kitchen and serves customers straight from its own window (the port). Park it anywhere — a street, a festival, a parking lot — and it works immediately. The old way of running apps was the opposite: like a stall that can only operate inside a shopping mall, depending on the mall's kitchen and plumbing to function. A port-binding app is the food truck: it's complete on its own and runs wherever you put it.

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.

❌ Old way ✅ Port binding Pre-installed web server (Apache) your app dropped inside, can't run alone App depends on the server to exist your app + its own web server :PORT 🌐 traffic App is complete; platform routes traffic to its port
Old way: the app lives inside a separate server. Port binding: the app is self-contained and simply exposes a port for traffic.

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
✅ Do Read the port from the PORT environment variable. Platforms (Heroku, Cloud Run, Kubernetes) tell your app which port to use this way.
⚠️ Avoid Hard-coding a port, or assuming a separate web server is already installed and configured on the host.
💡
Why it matters: A self-contained, port-binding app can become a backing service for another app — and it runs identically on your laptop and in the cloud. This is precisely how containers work.
8

Concurrency

Handle more load by running more copies of the app
To handle more load, run more processes (scale out / horizontally) rather than making one process bigger (scale up / vertically).
🛒 Analogy A busy supermarket opens more checkout lanes rather than building one giant super-register. When the rush ends, lanes close. That's horizontal scaling.

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.

Normal traffic web worker 2 web + 1 worker copy is enough traffic ↑ add copies Busy: just run more copies web worker scale each type independently web = answers visitor requests worker = runs background jobs
When traffic rises, you don't rebuild the app — you simply run more identical copies of each process type. This only works because the copies are stateless (Factor 6).

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
📌 Not the same as threads "Concurrency" here means running more separate processes — not adding threads or async tasks inside one process. Threads still live inside a single program on a single machine and eventually hit its limits; adding whole processes lets you spread the work across many machines instead. Use threads/async too if you like, but the 12-factor way to scale is to add processes.
📌 Note This works because processes are stateless (Factor 6). Each new copy is interchangeable, so a load balancer can spread requests across all of them. Today, autoscalers and Kubernetes do this automatically.
💡
Why it matters: Horizontal scaling is cheaper, more reliable (no single huge machine to fail), and near-instant. It's the cloud's superpower — and it only works if your app is built for it.
9

Disposability

Maximize robustness with fast startup and graceful shutdown
Processes should start quickly and shut down cleanly. They can be killed at any moment without causing damage.
🚕 Analogy Think of a taxi stand outside a station. Each cab is like one running copy of your app, and the line of waiting passengers is the incoming traffic. Any single cab can pull away or be replaced at any moment — the passenger simply takes the next cab that rolls up, and the queue keeps moving. Because no single cab is irreplaceable, the service never stops, even though individual cabs come and go constantly. Your app processes should work the same way: any one can be shut down or restarted without disrupting users.

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.
Start ready in seconds Serving requests SIGTERM "please stop" finish current work Exit clean, code 0 A healthy lifecycle: boot fast, serve, then on the stop signal wind down gracefully — never drop work.
The life of a disposable process — quick to start, clean to stop.

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.

📌 SIGTERM, then SIGKILL The platform is polite but not infinitely patient. When you run 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.
✅ Do Design background tasks so that running the same one twice is harmless (the technical word is idempotent). That way, if a worker dies halfway through a task and the platform retries it, nothing breaks. For example, before charging a customer, first check "has this order already been paid?" — so a retry can't bill them twice.
⚠️ Avoid Long, fragile startup routines, or ignoring shutdown signals — the platform will hard-kill the process and you'll lose in-flight work.
💡
Why it matters: Cloud platforms move, restart, and scale processes constantly. Disposable processes turn these disruptions into non-events, giving you smooth deploys and self-healing systems.
10

Dev/prod parity

Keep development, staging, and production as similar as possible
Shrink the gaps between your laptop and production — in time, people, and tools — so "works locally" reliably means "works in production."
🎭 Analogy Picture a theatre group preparing a play. Their rehearsals are like developing and testing the app on your own machine; opening night in front of a paying audience is like running it live in production. If they rehearse in a tiny room in street clothes, then step onto the real stage for the first time on opening night, things go wrong — props don't fit, lighting cues miss, actors trip. But if they rehearse on the actual stage with the real costumes and lighting, opening night holds almost no surprises. Keeping your development setup as close as possible to production is that full dress rehearsal.

The three gaps to close

GapTraditional12-factor
TimeWeeks between writing and deploying codeHours — deploy continuously
PersonnelDevs write, separate ops team deploysThe people who write code deploy it
ToolsSQLite locally, PostgreSQL in prodSame backing services everywhere
⚠️ The classic trap Using a lightweight stand-in locally (SQLite, an in-memory queue) but the "real thing" in production. Tiny behavioral differences cause bugs that only appear in production. Run the same Postgres/Redis locally — containers make this easy.

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
✅ Do Use Docker / Docker Compose to run the same database, cache, and queue versions locally that you run in production.
💡
Why it matters: High parity is what makes continuous deployment safe. The smaller the gap, the more confidently and frequently you can ship.
11

Logs

Treat logs as event streams
Your app shouldn't manage log files. It just prints events to the screen (stdout); the environment captures, routes, and stores that stream.
📻 Analogy A radio station broadcasts continuously and doesn't care who's recording. It just transmits. Listeners (log systems) decide whether to record, archive, or analyze the signal.

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

📖 What's "stdout"? stdout ("standard output") is just the normal place a program prints text to — the same stream you see when a script does 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.
your app just prints events one continuous stream (stdout) {"event":"login","user":42} {"event":"order","id":99} {"event":"error","code":500} 💻 your terminal (dev) 🔍 search & alert tool (prod) 🗃️ long-term archive The app doesn't know or care where the stream ends up — the environment routes it.
Logs are one event stream. The app emits it; the environment decides where it goes.

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

📖 What's "unbuffered"? By default, programs often buffer output — they quietly hold a batch of lines in memory and print them all at once later, to be efficient. That's bad for logs: if the app crashes, the held-back lines are lost, and you can't watch events live. Unbuffered means "write each line out immediately." In Python you get this with flush=True (shown above) or by setting the environment variable PYTHONUNBUFFERED=1.
📌 Where the logs go In production, a log router (such as Fluentd) collects this stream and forwards it to a search-and-analysis system — ELK, Splunk, Datadog, Grafana Loki, or CloudWatch — where you can search, graph, and set up alerts. Your app never has to know about any of that.
💡
Why it matters: Because the app only prints logs and never decides where they go, you can send that same output wherever you need — straight to your terminal while developing, or into a powerful search-and-alert tool in production — all without changing a single line of the app's code.
12

Admin processes

Run one-time jobs with your app's own code — not separate scripts on the side
Sometimes you need to run a task once, outside the app's normal job of serving users — for example, updating the database's structure after a change, or opening a console to inspect live data. Launch that task from your app's own code and settings, run it once, and let it finish.
🧩 Two kinds of work Your app's everyday job is the long-running part: it stays up and answers user requests all day. A one-off job is the opposite — you start it by hand when you need it, it does one specific thing (like a database update), and then it stops. This factor says: that one-off job should use the exact same code and configuration as the live app, so it behaves identically.
🔧 Analogy A mechanic fixing a car uses the same tools and parts the factory used to build it — not random tools from the garage next door. Your admin tasks use the same code and config as your running app.

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
✅ Do Ship admin scripts in the repo and run them against the same release. They get the same dependencies and the same config as the web processes.
⚠️ Avoid Running a maintenance script from your laptop against the production database with a different code version. That's how data gets corrupted.
💡
Why it matters: One-off processes that share the app's environment are reproducible and trustworthy. The same migration behaves identically in dev, staging, and prod.

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 toolWhich factors it enforces
Git / GitHub1 (one codebase, many deploys)
Docker image2 (declared deps), 5 (immutable build artifact), 7 (self-contained, port-binding), 10 (parity)
Env vars / secrets managers3 (config), 4 (backing services by URL)
Kubernetes / Cloud Run6 (stateless pods), 8 (replicas & autoscaling), 9 (fast start, SIGTERM), 11 (stdout collection)
CI/CD pipeline5 (build→release→run), 10 (continuous deployment)
K8s Jobs12 (one-off admin processes)
📌 A Dockerfile is 12-factor in action A typical Dockerfile declares dependencies (Factor 2), produces an immutable build artifact (Factor 5), bundles its own server on a port (Factor 7), and reads config from the environment (Factor 3) — several factors satisfied in one file.

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.

🧩 API first

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.

🔭 Telemetry / observability

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.

🔐 Security & authentication

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.

🩺 Health checks · bonus

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.

⚠️ A fair critique Factor 6's "store all state externally" is harder for genuinely stateful systems (databases themselves, stateful stream processors). Kubernetes added 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.

#FactorThe rule in one line
1CodebaseOne repo in version control, many deploys from it.
2DependenciesDeclare every dependency explicitly; isolate them.
3ConfigKeep config (secrets, URLs) in environment variables.
4Backing servicesTreat DBs, queues, caches as swappable attached resources.
5Build, release, runSeparate the three stages; releases are immutable.
6ProcessesRun stateless, share-nothing processes.
7Port bindingBe self-contained; export the service on a port.
8ConcurrencyScale out by running more processes.
9DisposabilityStart fast, shut down gracefully, survive being killed.
10Dev/prod parityKeep all environments as similar as possible.
11LogsWrite events to stdout; let the platform handle them.
12Admin processesRun one-off tasks in the same release and environment.
🎓 You made it! You now understand all twelve factors. Best next step: take a small app you've built, walk down this list, and find one factor you're not following yet. Fix that one. Repeat. That's how real systems become cloud-ready.

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: and worker:.
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 web processes that answer users.