The 12-Factor App: The Complete Guide¶
Every cloud-native application fails the same ways: it leaks config into code, stores state in memory that dies on restart, has a local dev environment that behaves nothing like production, or can't scale horizontally because it writes files to disk.
The 12-Factor App methodology is a set of twelve principles that prevent all of these failures. Written by engineers at Heroku in 2011, it remains the most practical and universally applicable guide to building software-as-a-service applications. This guide takes you through every factor with real code, real anti-patterns, and how each principle translates to modern cloud-native deployments.
What Is the 12-Factor App?¶
The 12-Factor App is a methodology for building modern software applications that:
- Use declarative formats for setup automation
- Have a clean contract with the underlying operating system
- Are suitable for deployment on cloud platforms
- Minimize divergence between development and production
- Can scale up without significant changes to tooling, architecture, or development practices
It was born from observing the patterns of thousands of apps deployed on Heroku. It's not a framework or a library — it's a set of principles that cut across any language or stack.
12-Factor Goal:
┌──────────────────────────────────────────────────────────┐
│ Write code once → Deploy anywhere → Scale without pain │
└──────────────────────────────────────────────────────────┘
The Three Failure Modes it prevents:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Code/Config │ │ State/Coupling │ │ Dev/Prod Gap │
│ Entanglement │ │ │ │ │
│ │ │ "It works on my │ │ "But staging │
│ "I have to │ │ machine. The │ │ is fine." │
│ change code to │ │ server has a │ │ │
│ deploy to prod"│ │ different IP" │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
The 12 Factors at a Glance¶
| # | Factor | One Line |
|---|---|---|
| 1 | Codebase | One repo, many deploys |
| 2 | Dependencies | Declare everything, assume nothing |
| 3 | Config | Environment separates you from your app |
| 4 | Backing Services | Everything external is a resource to attach |
| 5 | Build, Release, Run | Separate stages, immutable artifacts |
| 6 | Processes | Stateless by default, share nothing |
| 7 | Port Binding | Your app is self-contained and exports its own port |
| 8 | Concurrency | Scale out by adding processes, not threads |
| 9 | Disposability | Start fast, shut down gracefully |
| 10 | Dev/Prod Parity | Close the gap between environments |
| 11 | Logs | Streams, not files |
| 12 | Admin Processes | One-off tasks run in the same environment |
Factor 1: Codebase — One Repo, Many Deploys¶
"One codebase tracked in revision control, many deploys."
The Rule¶
There is a one-to-one relationship between a codebase and an app. Multiple apps cannot share a codebase — if they do, they should be refactored into libraries that can be included as dependencies.
A deploy is a running instance of the app. You can have many deploys of the same codebase: production, staging, a developer's local machine. They all run from the same code, just different versions or configs.
ONE CODEBASE → MANY DEPLOYS
git repo
─────────
main branch ──────→ Production (v1.4.2)
staging tag ──────→ Staging (v1.4.3-rc1)
local clone ──────→ Developer A (current HEAD)
local clone ──────→ Developer B (feature branch)
Anti-Pattern: Config in Code¶
# BAD: different branches for different environments
git checkout prod-branch # has prod DB url hardcoded
git checkout staging-branch # has staging DB url hardcoded
git checkout dev-branch # has local DB url hardcoded
This means you can never deploy the same artifact to multiple environments. Your build process produces different binaries for each environment. This is a maintenance nightmare.
Anti-Pattern: Shared Codebase¶
# BAD: two apps living in one repo and sharing source code
my-monolith/
├── web_app/
│ └── main.py # imports from shared/
├── worker_app/
│ └── main.py # also imports from shared/
└── shared/
└── models.py # shared business logic
If shared/ is the dependency, extract it into a proper library with versioning and let each app depend on it explicitly.
The 12-Factor Way¶
# GOOD: separate repos, shared code is a library
github.com/mycompany/order-service # one app
github.com/mycompany/notification-service # another app
github.com/mycompany/shared-models # shared library (versioned)
What "Codebase" Means for Microservices¶
In a microservices architecture, each service is its own app with its own codebase. A monorepo (one git repo with multiple services) is not a violation of Factor 1, as long as each service has its own independent deployment pipeline and produces its own artifact.
monorepo/
├── services/
│ ├── orders/ → builds to orders-service:v1.2.0 image
│ ├── payments/ → builds to payments-service:v2.0.1 image
│ └── catalog/ → builds to catalog-service:v1.0.5 image
└── libs/
└── shared/ → versioned internal library
Factor 2: Dependencies — Declare Everything, Assume Nothing¶
"Explicitly declare and isolate dependencies."
The Rule¶
A 12-factor app never relies on the implicit existence of system-wide packages. All dependencies are declared completely and explicitly in a dependency declaration manifest. It uses a dependency isolation tool to ensure no dependencies leak in from the surrounding system.
The Hidden Dependency Problem¶
# BAD: code assumes ImageMagick is installed on the server
import subprocess
def resize_image(path: str, width: int) -> None:
# This will silently fail if ImageMagick isn't installed
subprocess.run(["convert", path, "-resize", str(width), path])
This app has an implicit dependency on ImageMagick being installed at the system level. It will work on your laptop (because you installed it), break on a fresh server (because nobody installed it), and fail silently or loudly depending on the OS.
The 12-Factor Way: Explicit Declaration¶
# pyproject.toml — every dependency explicit and versioned
[project]
name = "order-service"
version = "1.0.0"
dependencies = [
"fastapi==0.111.0",
"sqlalchemy==2.0.30",
"pydantic==2.7.0",
"Pillow==10.3.0", # image processing — no system ImageMagick needed
"httpx==0.27.0",
]
[tool.uv]
dev-dependencies = [
"pytest==8.2.0",
"pytest-asyncio==0.23.7",
]
Lock files are non-negotiable
Always commit your lock file (package-lock.json, poetry.lock, go.sum, uv.lock). It pins every transitive dependency to exact versions, making builds reproducible across all machines and at any point in the future.
What "Isolation" Means¶
Isolation means the app's dependency environment cannot be contaminated by system packages. Tools that provide this:
| Language | Isolation Tool |
|---|---|
| Python | venv, uv, conda |
| Node.js | node_modules (local to project) |
| Go | Module cache (isolated by go.mod) |
| Java | Maven/Gradle local repository |
| Any | Docker (the universal answer) |
Factor 3: Config — The Environment Separates You from Your App¶
"Store config in the environment."
The Rule¶
Config is everything that varies between deploys (staging, production, developer environments). Code does not vary between deploys. The test: could you open-source your codebase right now without exposing credentials? If not, your config is leaking into your code.
What IS Config?¶
- Database connection strings and credentials
- External API keys (Stripe, SendGrid, Twilio)
- Resource handles (Redis URL, S3 bucket name)
- Feature flags
- Service URLs for other services
What is NOT Config?¶
- Internal constants that don't change between environments (tax rates hardcoded in your country's law, mathematical constants)
- Application-level configuration that's the same everywhere (routing rules, middleware setup)
Anti-Pattern: Config in Code¶
# BAD: config hardcoded in source code
DATABASE_URL = "postgresql://admin:SuperSecret123@prod-db.internal:5432/orders"
STRIPE_KEY = "sk_live_abc123xyz..."
REDIS_URL = "redis://redis.internal:6379/0"
# BAD: config in a committed config file
# config/production.py (in git!)
DEBUG = False
DATABASE_HOST = "prod-db.internal"
DATABASE_PASSWORD = "SuperSecret123"
Config in code is a security incident waiting to happen
Every developer who ever cloned the repo now has your production database password. It's in git history forever, even after you "delete" it. This is how credentials get leaked.
The 12-Factor Way: Environment Variables¶
# GOOD: all config from environment variables
import os
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
stripe_secret_key: str
redis_url: str
debug: bool = False
log_level: str = "INFO"
max_connections: int = 10
class Config:
env_file = ".env" # loaded in dev, ignored in prod
env_file_encoding = "utf-8"
@lru_cache
def get_settings() -> Settings:
return Settings()
# .env (local development — NEVER commit this)
DATABASE_URL=postgresql://postgres:password@localhost:5432/orders_dev
STRIPE_SECRET_KEY=sk_test_abc123
REDIS_URL=redis://localhost:6379/0
DEBUG=true
LOG_LEVEL=DEBUG
# .env.example (commit this — shows what vars are needed, no real values)
DATABASE_URL=postgresql://user:password@host:5432/dbname
STRIPE_SECRET_KEY=sk_test_...
REDIS_URL=redis://localhost:6379/0
DEBUG=false
LOG_LEVEL=INFO
Config in Kubernetes¶
In Kubernetes, environment variables come from ConfigMaps (non-sensitive) and Secrets (sensitive):
# configmap.yaml — non-sensitive config
apiVersion: v1
kind: ConfigMap
metadata:
name: order-service-config
data:
LOG_LEVEL: "INFO"
MAX_CONNECTIONS: "20"
FEATURE_NEW_CHECKOUT: "true"
---
# secret.yaml — sensitive values (base64 encoded, ideally from a secret manager)
apiVersion: v1
kind: Secret
metadata:
name: order-service-secrets
type: Opaque
stringData: # stringData lets you write plain text, k8s encodes it
DATABASE_URL: "postgresql://user:pass@db-service:5432/orders"
STRIPE_SECRET_KEY: "sk_live_..."
# deployment.yaml — inject config into the container
spec:
containers:
- name: order-service
image: mycompany/order-service:v1.2.0
envFrom:
- configMapRef:
name: order-service-config
- secretRef:
name: order-service-secrets
env:
- name: POD_NAME # expose Kubernetes metadata as env vars
valueFrom:
fieldRef:
fieldPath: metadata.name
Beyond env vars: secrets managers
For production, environment variables in plain Kubernetes Secrets are stored base64-encoded (not encrypted). For true security at scale, use a secrets manager:
- HashiCorp Vault with the Vault Agent Injector
- AWS Secrets Manager with the AWS Secrets Store CSI Driver
- GCP Secret Manager or Azure Key Vault equivalents
These systems inject secrets at runtime, rotate them automatically, and provide audit logs.
Factor 4: Backing Services — Attach and Detach Resources¶
"Treat backing services as attached resources."
The Rule¶
A backing service is any service the app consumes over the network as part of its normal operation: databases, message queues, caches, email services, payment APIs. A 12-factor app makes no distinction between local and third-party services. Both are accessed via a URL or credentials stored in config.
BACKING SERVICES — all treated the same way:
┌─────────────────┐
│ Your App │
└────────┬────────┘
│ accessed via URL/credentials in environment config
┌─────┴──────────────────────────────────┐
│ │
▼ ▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ DB │ │Redis │ │Kafka │ │SMTP │ │Stripe│
│(your │ │(your │ │(your │ │(Send │ │(3rd │
│ own) │ │ own) │ │ own) │ │ Grid)│ │party)│
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘
The app treats them identically — just a URL and credentials in environment variables. It doesn't care if the database is running on localhost or on AWS RDS.
The "Attach/Detach" Power¶
The real power: you can swap a backing service without changing code, only config.
# GOOD: database is just a URL — the app doesn't know where it lives
class OrderRepository:
def __init__(self, database_url: str):
self._engine = create_engine(database_url)
def save(self, order: Order) -> None:
with self._engine.connect() as conn:
conn.execute(...)
# Swap from local Postgres to RDS — zero code changes
# Before
DATABASE_URL=postgresql://postgres:pass@localhost:5432/orders
# After (just change the env var)
DATABASE_URL=postgresql://orders_user:prod_pass@orders.abc123.us-east-1.rds.amazonaws.com:5432/orders
Anti-Pattern: Service Discovery Hardcoded¶
# BAD: app knows exactly where the database lives
class OrderRepository:
def __init__(self):
# hardcoded — can't swap without code change
self._conn = psycopg2.connect(
host="10.0.1.42",
port=5432,
database="orders",
user="admin",
password="secret"
)
Real-World Backing Services Catalog¶
| Type | Local Dev | Production |
|---|---|---|
| Relational DB | PostgreSQL (Docker) | AWS RDS, Cloud SQL, Neon |
| Cache | Redis (Docker) | Elasticache, Upstash |
| Message Queue | RabbitMQ (Docker) | AWS SQS, Google Pub/Sub, Kafka |
| Object Storage | MinIO (Docker) | AWS S3, GCS, Azure Blob |
| Search | Elasticsearch (Docker) | Elastic Cloud, OpenSearch |
| Mailhog (Docker) | SendGrid, AWS SES | |
| SMS | Mock server | Twilio |
Docker Compose for local backing services
Run all your backing services locally with Docker Compose so they match production behavior exactly:
# docker-compose.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: orders_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
kafka:
image: confluentinc/cp-kafka:7.6.1
environment:
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
ports:
- "9092:9092"
Factor 5: Build, Release, Run — Strictly Separate Stages¶
"Strictly separate build and run stages."
The Rule¶
A codebase is transformed into a deploy through three stages:
- Build: Convert the code repo into an executable bundle (compile, bundle assets, fetch dependencies). The build knows nothing about the target environment.
- Release: Combine the build artifact with the deploy's config. Every release has a unique ID and is immutable.
- Run: Execute the app in the execution environment by launching a set of the app's processes against a selected release.
BUILD → RELEASE → RUN
Source Code ──→ Build Process ──→ Build Artifact
│
(add config)
│
▼
Release (artifact + config) ──→ Run (processes)
Build Artifact: Docker image, compiled binary, zip bundle
Release ID: v1.4.2, git SHA, timestamp — never mutable
Why Immutable Releases Matter¶
You can never change a release once it's made. If something needs to change, make a new release. This enables:
- Instant rollback: just activate a previous release
- Audit trail: know exactly what code was running at any point in time
- No surprises: what you tested in staging is exactly what runs in production
Anti-Pattern: SSH into Production to Fix Things¶
# BAD: "just a quick hotfix" in production
ssh prod-server-01
vim /app/orders/pricing.py # edit code directly on the server
kill -HUP $(cat /var/run/app.pid) # reload
# Now prod and your codebase are out of sync. Forever.
This is sometimes called "cowboy deployment." It breaks every subsequent deployment because the server has undocumented local changes.
The 12-Factor Way: CI/CD Pipeline¶
# .github/workflows/deploy.yml
name: Build → Release → Deploy
on:
push:
branches: [main]
jobs:
build:
name: Build
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Build Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
mycompany/order-service:${{ github.sha }}
mycompany/order-service:latest
# The image IS the build artifact — immutable, content-addressed
release-staging:
name: Release → Staging
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy to staging
run: |
# Combine build artifact (image) + staging config (env vars)
kubectl set image deployment/order-service \
order-service=mycompany/order-service:${{ github.sha }} \
--namespace=staging
kubectl rollout status deployment/order-service --namespace=staging
release-production:
name: Release → Production
needs: release-staging
runs-on: ubuntu-latest
environment: production # requires manual approval
steps:
- name: Deploy to production
run: |
kubectl set image deployment/order-service \
order-service=mycompany/order-service:${{ github.sha }} \
--namespace=production
The Build stage should never access production config
The build produces the same artifact regardless of where it will be deployed. The config is only injected at the Release stage. This is why baking secrets into Docker images is an anti-pattern.
Factor 6: Processes — Stateless, Share Nothing¶
"Execute the app as one or more stateless processes."
The Rule¶
12-factor processes are stateless and share nothing. Any data that needs to persist must be stored in a stateful backing service (database, cache). The process itself holds no persistent state.
This is the single most important factor for cloud scalability.
Why Statelessness Enables Scale¶
STATEFUL (broken scaling):
User → Load Balancer → Server A (has user's session in memory)
→ Server B (user's session NOT here → broken!)
STATELESS (correct scaling):
User → Load Balancer → Server A (reads session from Redis) ✓
→ Server B (also reads session from Redis) ✓
Any server can handle any request. Add more servers freely.
Anti-Pattern: In-Process State¶
# BAD: storing state in the process
# This breaks the moment you run a second instance
class OrderService:
_pending_orders: dict = {} # in-memory state — lost on restart
_user_sessions: dict = {} # will be empty on the next server
_uploaded_files: list = [] # different on every instance
def save_draft_order(self, user_id: str, order: dict):
self._pending_orders[user_id] = order # only on THIS server
def get_draft_order(self, user_id: str):
return self._pending_orders.get(user_id) # won't be here on server B
Anti-Pattern: Sticky Sessions¶
# BAD: nginx sticky sessions — routing users to the same server
upstream backend {
ip_hash; # forces same user to same server
server 10.0.1.10;
server 10.0.1.11;
}
Sticky sessions are a band-aid that hides a stateful application. The server you're "stuck" to becomes a single point of failure.
The 12-Factor Way: External State¶
# GOOD: all state in backing services
import redis
import json
from uuid import UUID
class OrderDraftService:
def __init__(self, redis_client: redis.Redis, db: Database):
self._redis = redis_client
self._db = db
def save_draft(self, user_id: UUID, order_data: dict) -> None:
# Store draft in Redis (survives process restart)
key = f"draft_order:{user_id}"
self._redis.setex(key, 3600, json.dumps(order_data)) # 1 hour TTL
def get_draft(self, user_id: UUID) -> dict | None:
key = f"draft_order:{user_id}"
data = self._redis.get(key)
return json.loads(data) if data else None
def confirm_order(self, user_id: UUID) -> UUID:
draft = self.get_draft(user_id)
if not draft:
raise DraftNotFoundError(user_id)
order = Order.from_draft(draft)
self._db.save(order) # persist to database
self._redis.delete(f"draft_order:{user_id}") # clean up
return order.id
# Session storage in backing service (not in-process)
from fastapi import FastAPI, Request
from starlette.middleware.sessions import SessionMiddleware
import redis
app = FastAPI()
# Sessions stored in Redis — stateless from the app's perspective
app.add_middleware(
SessionMiddleware,
secret_key=os.getenv("SESSION_SECRET"),
# configure Redis as session backend
)
Uploaded Files: Never Local Disk¶
# BAD: files written to local disk
def upload_receipt(file: UploadFile):
with open(f"/tmp/receipts/{file.filename}", "wb") as f:
f.write(await file.read()) # will be gone on pod restart
return f"/tmp/receipts/{file.filename}"
# GOOD: files in object storage (S3, GCS, etc.)
import boto3
def upload_receipt(file: UploadFile) -> str:
s3 = boto3.client("s3")
key = f"receipts/{uuid4()}/{file.filename}"
s3.upload_fileobj(file.file, "my-bucket", key)
return f"s3://my-bucket/{key}" # URL survives process death
Factor 7: Port Binding — Your App Exports Its Own Port¶
"Export services via port binding."
The Rule¶
A 12-factor app is completely self-contained and does not rely on runtime injection of a web server. The app exports HTTP (or any other protocol) by binding to a port and listening for requests on that port.
No Apache, no Nginx as a prerequisite — the web server is just another dependency declared in the manifest.
TRADITIONAL (server installs the app):
Apache/Nginx is installed → App is a plugin/module → Apache serves it
12-FACTOR (app self-contains the server):
App starts → Binds port 8000 → Anyone connecting to :8000 gets a response
In Practice¶
// main.go
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8000"
}
http.HandleFunc("/health", healthHandler)
http.HandleFunc("/orders/", ordersHandler)
fmt.Printf("Order service listening on :%s\n", port)
http.ListenAndServe(":"+port, nil)
}
Port Binding in Kubernetes¶
In Kubernetes, the port is declared in the container spec and exposed via a Service:
# deployment.yaml
spec:
containers:
- name: order-service
image: mycompany/order-service:v1.2.0
ports:
- containerPort: 8000 # the port the app binds to
env:
- name: PORT
value: "8000"
---
# service.yaml — routes traffic to the container port
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- port: 80 # port clients connect to
targetPort: 8000 # port the container listens on
One app → one port, not one app → many ports
Each 12-factor app binds exactly one port. If you need to expose both HTTP and a metrics endpoint, use two separate ports on the same app (8000 for HTTP, 9090 for Prometheus metrics) or run a sidecar container.
Factor 8: Concurrency — Scale Out with Processes¶
"Scale out via the process model."
The Rule¶
In the 12-factor app, processes are a first-class citizen. Different types of work are handled by different process types, and the app scales by running more processes — not by making one process bigger.
PROCESS MODEL — one app, multiple process types:
┌─────────────────────────────────────────────┐
│ ORDER SERVICE │
├─────────────────┬───────────────────────────┤
│ web │ worker │
│ (HTTP handler) │ (background jobs) │
│ │ │
│ 3 processes │ 5 processes │
│ (light load) │ (heavy load) │
└─────────────────┴───────────────────────────┘
Scale web processes when HTTP traffic spikes. Scale worker processes when the job queue backs up. Each independently, without affecting the other.
Vertical vs Horizontal Scaling¶
VERTICAL SCALING (pre-12-factor):
One fat process → add more CPU/RAM to the machine
Limit: one machine, one process can only get so big
HORIZONTAL SCALING (12-factor):
Many small processes → add more instances
Limit: practically unlimited (cloud elasticity)
Process Types¶
# Procfile (Heroku) or Kubernetes deployments — declares process types
web: uvicorn main:app --host 0.0.0.0 --port $PORT
worker: celery -A tasks worker --loglevel=info --concurrency=4
scheduler: celery -A tasks beat --loglevel=info
# Kubernetes: separate Deployments for separate process types
---
# web process
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service-web
spec:
replicas: 3 # scale independently
template:
spec:
containers:
- name: order-service
image: mycompany/order-service:v1.2.0
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
---
# worker process — same image, different command
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service-worker
spec:
replicas: 5 # scale independently from web
template:
spec:
containers:
- name: order-service
image: mycompany/order-service:v1.2.0 # same image!
command: ["celery", "-A", "tasks", "worker"]
Auto-Scaling in Kubernetes¶
# Horizontal Pod Autoscaler — automatic process scaling
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-web-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service-web
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
Threads are not a substitute for processes
You can use threads within a process for I/O concurrency, but you should not rely on threading for scaling. Threads share memory, which requires synchronization, introduces bugs, and is limited by one machine. Processes are isolated, stateless, and independently deployable — use them for scale.
Factor 9: Disposability — Start Fast, Shut Down Gracefully¶
"Maximize robustness with fast startup and graceful shutdown."
The Rule¶
The app's processes can be started or stopped at any moment. This means:
- Fast startup: minimize time from process start to ready to serve requests (aim for < 5 seconds)
- Graceful shutdown: on receiving SIGTERM, stop accepting new requests, finish current work, then exit cleanly
Disposable processes enable rapid deploys, elastic scaling, and resilience to failures.
Why It Matters¶
In Kubernetes, pods are created and destroyed constantly — during deploys, scaling events, node failures, and upgrades. If your app takes 60 seconds to start, your rolling deploy is painfully slow. If it doesn't handle SIGTERM, in-flight requests are dropped on every deployment.
Graceful Shutdown¶
import signal
import asyncio
from fastapi import FastAPI
import uvicorn
app = FastAPI()
is_shutting_down = False
@app.get("/health/live")
def liveness():
return {"status": "alive"}
@app.get("/health/ready")
def readiness():
# Stop serving traffic before we actually shut down
if is_shutting_down:
return {"status": "shutting_down"}, 503
return {"status": "ready"}
@app.on_event("shutdown")
async def shutdown_event():
global is_shutting_down
is_shutting_down = True
# Finish in-flight requests (uvicorn handles this)
# Close database connections cleanly
await database.disconnect()
# Drain the message queue consumer
await message_consumer.stop()
print("Shutdown complete")
# Worker with graceful shutdown
import signal
import sys
from celery import Celery
app = Celery("tasks")
def graceful_shutdown(signum, frame):
print("Received SIGTERM, finishing current task and shutting down...")
# Celery's warm shutdown: finish current task, don't accept new ones
app.control.broadcast("shutdown", destination=[f"celery@{socket.gethostname()}"])
sys.exit(0)
signal.signal(signal.SIGTERM, graceful_shutdown)
Kubernetes Health Checks¶
# deployment.yaml — health probes tell Kubernetes about app state
spec:
containers:
- name: order-service
image: mycompany/order-service:v1.2.0
# Startup probe: is the app done initializing?
startupProbe:
httpGet:
path: /health/live
port: 8000
failureThreshold: 30 # allow up to 30 * 10s = 5 min to start
periodSeconds: 10
# Liveness probe: should Kubernetes restart this pod?
livenessProbe:
httpGet:
path: /health/live
port: 8000
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 3
# Readiness probe: should this pod receive traffic?
readinessProbe:
httpGet:
path: /health/ready
port: 8000
periodSeconds: 5
failureThreshold: 2
# Give the app time to finish in-flight requests
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
terminationGracePeriodSeconds: 30 # total time before SIGKILL
Fast Startup Tips¶
- Lazy-load expensive resources (ML models, connection pools) — or pre-warm them in a startup hook
- Use connection pooling so you don't create 100 DB connections at startup
- Keep Docker image layers small — Alpine base images, multi-stage builds
- Avoid running database migrations at startup (Factor 12 covers this)
Factor 10: Dev/Prod Parity — Close Every Gap¶
"Keep development, staging, and production as similar as possible."
The Rule¶
The traditional gap between development and production has three dimensions. The 12-factor app is designed to make all three as small as possible.
THE THREE GAPS:
┌────────────────┬────────────────────────┬──────────────────────────┐
│ Gap │ Traditional │ 12-Factor │
├────────────────┼────────────────────────┼──────────────────────────┤
│ Time Gap │ Weeks or months between│ Deploy hours or minutes │
│ │ dev and production │ after writing the code │
├────────────────┼────────────────────────┼──────────────────────────┤
│ Personnel Gap │ Devs write code, │ Devs who write code also │
│ │ ops deploys it │ own deployment │
├────────────────┼────────────────────────┼──────────────────────────┤
│ Tools Gap │ Nginx in prod, │ Same tools everywhere │
│ │ SQLite locally │ (Docker makes this easy) │
└────────────────┴────────────────────────┴──────────────────────────┘
The Tools Gap: The Most Dangerous Gap¶
The tools gap is the most common source of "it works on my machine" bugs:
| Service | Common Local Shortcut | Why It's Dangerous |
|---|---|---|
| Database | SQLite (local) vs PostgreSQL (prod) | Different SQL dialects, different NULL handling, different performance characteristics |
| Cache | Dictionary cache (local) vs Redis (prod) | Local cache works, Redis has network latency and serialization |
| Message Queue | In-process queue vs Kafka (prod) | Different ordering guarantees, different failure modes |
| Print to console vs SendGrid (prod) | Console email never bounces, SendGrid does | |
| File Storage | Local filesystem vs S3 (prod) | Local files survive restarts, S3 doesn't work the same |
The 12-Factor Way: Docker Compose for Parity¶
# docker-compose.yml — production-equivalent services locally
services:
app:
build: .
env_file: .env
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
postgres:
image: postgres:16-alpine # SAME major version as production
environment:
POSTGRES_DB: orders_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine # SAME major version as production
kafka:
image: confluentinc/cp-kafka:7.6.1 # SAME as production
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
depends_on:
- zookeeper
zookeeper:
image: confluentinc/cp-zookeeper:7.6.1
mailhog:
image: mailhog/mailhog # catches emails in dev, shows them in a UI
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
Mailhog: a perfect dev/prod parity tool
Mailhog is an SMTP server for development that captures all outgoing emails and shows them in a web UI at localhost:8025. Your app talks to smtp://mailhog:1025 locally and smtp://sendgrid:587 in production. Same SMTP protocol, same code — only the SMTP_HOST env var changes.
Factor 11: Logs — Streams, Not Files¶
"Treat logs as event streams."
The Rule¶
A 12-factor app never concerns itself with routing or storage of its output stream. It writes all logs to stdout (and optionally stderr) as an unbuffered stream of events, one event per line.
The execution environment — not the app — is responsible for capturing, routing, and storing those streams.
Why Stdout, Not Files?¶
LOG FILES (anti-pattern):
App writes → /var/log/app.log → need to rotate → need to ship → logstash polls the file
Problems: log rotation complexity, disk fills up, hard to aggregate across 20 pods
STDOUT (12-factor):
App writes → stdout → container runtime captures → log aggregator collects
Works the same regardless of how many pods you have. Zero configuration in the app.
Structured Logging¶
Plain text logs are hard to query. Structured logs (JSON) let you filter, aggregate, and alert on specific fields.
# GOOD: structured JSON logging
import structlog
import logging
import sys
def configure_logging():
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(), # output as JSON
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
)
log = structlog.get_logger()
# Each log line is a JSON object — queryable, parseable, monitorable
log.info("order_confirmed",
order_id=str(order.id),
customer_id=str(order.customer_id),
total_amount=str(order.total.amount),
currency=order.total.currency,
item_count=len(order.line_items),
duration_ms=elapsed_ms
)
Output:
{"event": "order_confirmed", "level": "info", "timestamp": "2026-05-18T10:23:41Z",
"order_id": "a1b2c3", "customer_id": "x9y8z7",
"total_amount": "149.99", "currency": "USD", "item_count": 3, "duration_ms": 42}
Log Routing in Production¶
App (writes to stdout)
│
▼
Container Runtime (Docker/containerd captures stdout)
│
▼
Log Collector (Fluentbit, Promtail, Fluent, CloudWatch Agent)
│
▼
Log Storage & Search (Loki, Elasticsearch, CloudWatch Logs, Splunk)
│
▼
Visualization & Alerting (Grafana, Kibana, CloudWatch Dashboards)
# Kubernetes: Fluentbit DaemonSet collects logs from all pods automatically
# No configuration needed in the app — it just writes to stdout
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: fluent-bit
namespace: logging
spec:
selector:
matchLabels:
name: fluent-bit
template:
spec:
containers:
- name: fluent-bit
image: fluent/fluent-bit:3.1
volumeMounts:
- name: varlog
mountPath: /var/log # reads from node log directory
volumes:
- name: varlog
hostPath:
path: /var/log
Correlation IDs: the key to distributed tracing
In a microservices system, a single user request may touch 5 services. Add a correlation_id (or trace_id) to every log entry to trace a request across services:
import uuid
from fastapi import Request
import structlog
async def correlation_id_middleware(request: Request, call_next):
correlation_id = request.headers.get("X-Correlation-ID", str(uuid.uuid4()))
structlog.contextvars.bind_contextvars(correlation_id=correlation_id)
response = await call_next(request)
response.headers["X-Correlation-ID"] = correlation_id
return response
Factor 12: Admin Processes — One-Off Tasks Run the Same Way¶
"Run admin/management tasks as one-off processes."
The Rule¶
One-off administrative or management tasks — database migrations, data backups, REPL sessions for inspection — should be run in an identical environment to the regular app processes. They use the same codebase, same config, same dependency set. They are run as one-off processes, not as part of the app's regular process formation.
Examples of Admin Processes¶
- Database schema migrations
- Clearing expired sessions from the database
- Seeding a new database with initial data
- Running a one-time data transformation script
- Inspecting the running state via a REPL
Anti-Pattern: Migrations on App Startup¶
# BAD: running migrations on every pod startup
@app.on_event("startup")
async def startup():
# This runs on EVERY pod startup:
# - Pod 1 starts migration
# - Pod 2 starts migration at the same time
# - Race condition, table locks, broken state
await run_migrations() # DON'T DO THIS
await database.connect()
The 12-Factor Way: Kubernetes Jobs¶
# Run migration as a separate one-off process before deploying the app
# Same image, same environment, different command
# Local / CI
docker run --rm \
--env-file .env.production \
mycompany/order-service:v1.2.0 \
python manage.py migrate
# Kubernetes: run migration as a Job before rolling out the new Deployment
apiVersion: batch/v1
kind: Job
metadata:
name: order-service-migrate-v1-2-0
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: mycompany/order-service:v1.2.0 # same image as app
command: ["python", "manage.py", "migrate"]
envFrom:
- configMapRef:
name: order-service-config
- secretRef:
name: order-service-secrets
# Helm hook: run migration before upgrade
# templates/migration-job.yaml
metadata:
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": hook-succeeded
Practical migration workflow
- Build the new Docker image with updated migration files
- Run the migration Job against the production database
- If migration succeeds, roll out the new Deployment
- If migration fails, the old Deployment is still running — no downtime
Beyond the 12 Factors¶
The 12-Factor App was written in 2011. Cloud-native technology has evolved significantly since then. Several additional factors have been proposed for modern applications.
Factor 13: API First¶
Design your service contract (API) before you write any implementation code. Every service is a platform — internal services should be as carefully designed as public APIs.
- Define your API using OpenAPI/Swagger, Protobuf, or AsyncAPI
- Generate server stubs and client libraries from the spec
- Version your APIs from day one
# openapi.yaml — define the contract first
openapi: "3.1.0"
info:
title: Order Service API
version: "1.0.0"
paths:
/orders:
post:
summary: Place an order
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PlaceOrderRequest'
responses:
'201':
description: Order created
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
Factor 14: Telemetry¶
Logs are not enough. A production cloud-native app needs three pillars of observability:
THE THREE PILLARS OF OBSERVABILITY
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ LOGS │ │ METRICS │ │ TRACES │
│ │ │ │ │ │
│ What happened│ │ How is it │ │ Why is it │
│ (events) │ │ performing? │ │ slow? │
│ │ │ (numbers) │ │ (causality) │
│ ELK, Loki │ │ Prometheus │ │ Jaeger, │
│ CloudWatch │ │ Grafana │ │ Zipkin, OTEL │
└──────────────┘ └──────────────┘ └──────────────┘
Use OpenTelemetry — the vendor-neutral standard for instrumentation:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
tracer = trace.get_tracer("order-service")
def confirm_order(order_id: str):
with tracer.start_as_current_span("confirm_order") as span:
span.set_attribute("order.id", order_id)
order = order_repo.find_by_id(order_id)
span.set_attribute("order.customer_id", str(order.customer_id))
order.confirm()
order_repo.save(order)
span.set_attribute("order.total", str(order.total.amount))
return order
Factor 15: Authentication and Authorization¶
Every service-to-service call and every client request should be authenticated. In cloud-native, this means:
- mTLS (mutual TLS) for service-to-service — service meshes like Istio handle this automatically
- JWT (JSON Web Tokens) for client authentication — validated at the API Gateway
- RBAC (Role-Based Access Control) for authorization
- Zero-trust networking — never assume a request is trusted because it came from inside the cluster
The 12-Factor Compliance Checklist¶
Use this as a pre-deployment checklist for every service:
| Factor | Compliance Check | Common Failure |
|---|---|---|
| 1 Codebase | ☐ One repo per service, all deploys from same code | Multiple git branches for different environments |
| 2 Dependencies | ☐ Lock file committed, no system-level assumptions | Missing package-lock.json, pip install without pinning |
| 3 Config | ☐ No secrets or URLs in code or committed config files | Hardcoded DB password in config.py |
| 4 Backing Services | ☐ All external services accessed via URL in env vars | DB host hardcoded, local SQLite in dev |
| 5 Build/Release/Run | ☐ CI builds the artifact, release adds config, run is separate | git pull on the server, SSH hotfixes |
| 6 Processes | ☐ No in-memory session state, no local filesystem writes | In-process session dict, files written to /tmp |
| 7 Port Binding | ☐ App exports its own port, no server prerequisite | Requires Apache/IIS to be pre-installed |
| 8 Concurrency | ☐ Horizontal scaling works, stateless | Cannot run 2+ instances due to state |
| 9 Disposability | ☐ Graceful SIGTERM handling, health endpoints | Drops in-flight requests on deploy, slow startup |
| 10 Dev/Prod Parity | ☐ Same DB engine locally and in prod | SQLite locally, PostgreSQL in prod |
| 11 Logs | ☐ Logs to stdout, structured JSON, no file writes | Writing to /var/log/app.log, no correlation IDs |
| 12 Admin Processes | ☐ Migrations run as one-off Jobs, not on startup | app.on_event("startup") runs migrations |
Summary¶
The 12-Factor App methodology is a discipline, not a framework. You don't install it — you apply it through deliberate decisions at every layer of your stack.
The factors cluster into three themes:
PORTABILITY (Factors 1-4)
Your app should run anywhere without special environment setup.
→ One codebase, explicit dependencies, config in env, services as resources
DEPLOYABILITY (Factors 5-9)
Your app should be deployable, scalable, and reliable without drama.
→ Separate stages, stateless processes, self-contained ports, horizontal scale, disposability
OPERABILITY (Factors 10-12)
Your app should be observable and manageable in production.
→ Prod parity, log streams, isolated admin tasks
Start here if you're beginning a new service:
- Set up your git repo and declare all dependencies with a lock file
- Create a
.env.examplewith every required config variable - Write a
docker-compose.ymlthat mirrors production services locally - Configure your Dockerfile to write logs to stdout and handle SIGTERM
- Set up a CI/CD pipeline that builds once and promotes that artifact through environments
- Add liveness and readiness probes
- Write migrations as standalone scripts, not startup hooks
Follow these practices consistently and you'll have a service that scales to millions of users without an architecture rewrite, survives server failures without data loss, deploys in seconds without downtime, and can be debugged in production without SSH access.
Have questions or want to discuss cloud-native patterns? Connect on LinkedIn, X or reach out via email.
Discussion
Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.