Skip to content

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)
# order-service/requirements.txt
shared-models==1.3.0   # explicit version, not a local path import

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",
]
# Dockerfile — isolated environment, no system dependencies leak in
FROM python:3.12-slim

WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev

COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
// package.json
{
  "name": "order-service",
  "version": "1.0.0",
  "dependencies": {
    "express": "4.19.2",
    "pg": "8.11.5",
    "sharp": "0.33.4"
  },
  "devDependencies": {
    "jest": "29.7.0",
    "typescript": "5.4.5"
  },
  "engines": {
    "node": ">=20.0.0"
  }
}
FROM node:20-alpine

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

COPY . .
CMD ["node", "src/index.js"]
// go.mod — all dependencies declared, pinned by go.sum
module github.com/mycompany/order-service

go 1.22

require (
    github.com/gin-gonic/gin v1.10.0
    github.com/jackc/pgx/v5 v5.6.0
    github.com/redis/go-redis/v9 v9.5.3
)

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
# .gitignore
.env
.env.*
!.env.example  # commit an example with no real values
# .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
Email 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:

  1. Build: Convert the code repo into an executable bundle (compile, bundle assets, fetch dependencies). The build knows nothing about the target environment.
  2. Release: Combine the build artifact with the deploy's config. Every release has a unique ID and is immutable.
  3. 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.py — the web server is a library, not a system dependency
from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
def health():
    return {"status": "ok"}

@app.get("/orders/{order_id}")
def get_order(order_id: str):
    ...
# The app binds its own port
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
// server.js — Express binds its own port
const express = require('express');
const app = express();
const PORT = process.env.PORT || 8000;

app.get('/health', (req, res) => res.json({ status: 'ok' }));

app.listen(PORT, '0.0.0.0', () => {
  console.log(`Order service listening on port ${PORT}`);
});
// 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
Email 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

  1. Build the new Docker image with updated migration files
  2. Run the migration Job against the production database
  3. If migration succeeds, roll out the new Deployment
  4. 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:

  1. Set up your git repo and declare all dependencies with a lock file
  2. Create a .env.example with every required config variable
  3. Write a docker-compose.yml that mirrors production services locally
  4. Configure your Dockerfile to write logs to stdout and handle SIGTERM
  5. Set up a CI/CD pipeline that builds once and promotes that artifact through environments
  6. Add liveness and readiness probes
  7. 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.