Python Twelve-Factor App — Deep Dive

Factor-by-Factor Implementation

Factor III: Config — The Practical Approach

Environment variables are the standard, but raw os.environ.get() scattered across your code is fragile. Use structured config with validation:

# config.py using pydantic-settings
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    redis_url: str = "redis://localhost:6379"
    debug: bool = False
    secret_key: str
    allowed_hosts: list[str] = ["*"]
    log_level: str = "INFO"

    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}

settings = Settings()

This gives you type validation, defaults, .env file support, and clear documentation of every config value. If SECRET_KEY is missing, the app fails at startup with a clear error — not halfway through a request.

For Django specifically:

# settings.py
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ.get("DB_NAME", "myapp"),
        "HOST": os.environ.get("DB_HOST", "localhost"),
        "PORT": os.environ.get("DB_PORT", "5432"),
        "USER": os.environ.get("DB_USER", "postgres"),
        "PASSWORD": os.environ.get("DB_PASSWORD", ""),
    }
}

Factor IV: Backing Services as Attached Resources

Design your code so backing services are swappable URLs:

# Each backing service is just a URL
DATABASE_URL = "postgresql://user:pass@db-host:5432/myapp"
CACHE_URL = "redis://cache-host:6379/0"
EMAIL_URL = "smtp://mail-host:587"
QUEUE_URL = "amqp://rabbit-host:5672"
STORAGE_URL = "s3://my-bucket"

Use libraries that accept URL-based configuration: dj-database-url for Django, SQLAlchemy with connection strings, redis-py with URLs. The pattern makes it trivial to switch between local development services and managed cloud services.

Factor VI: Stateless Processes in Practice

The most commonly violated factor. Symptoms of stateful processes:

  • File uploads stored on local disk — If the process restarts or the request hits a different instance, the file is gone. Use S3, GCS, or MinIO instead.
  • In-memory sessions — Flask’s default session uses signed cookies (stateless, good). Django’s default uses database sessions (also fine). Avoid in-process session stores.
  • Caches in global variables — A dict in your app module doesn’t survive restarts and isn’t shared across workers. Use Redis.
# Bad: stateful process
uploaded_files = {}  # Lost on restart, not shared across workers

@app.post("/upload")
async def upload(file: UploadFile):
    uploaded_files[file.filename] = await file.read()

# Good: stateless with external storage
import boto3
s3 = boto3.client("s3")

@app.post("/upload")
async def upload(file: UploadFile):
    s3.upload_fileobj(file.file, "my-bucket", file.filename)

Factor VIII: Concurrency Model

Python’s concurrency story for twelve-factor apps:

Web workers — Gunicorn with multiple workers for sync apps, Uvicorn workers for async:

# gunicorn.conf.py
workers = 4  # Typically 2-4x CPU cores
worker_class = "uvicorn.workers.UvicornWorker"
bind = "0.0.0.0:8000"
timeout = 30
graceful_timeout = 30

Background workers — Celery, RQ, or Dramatiq for task processing:

# Separate process type in Procfile
web: gunicorn app:app -c gunicorn.conf.py
worker: celery -A tasks worker --loglevel=info
beat: celery -A tasks beat --loglevel=info

Scale each process type independently. If your queue is backed up, add more workers without touching web processes. This is horizontal scaling in action.

Factor IX: Disposability

Graceful shutdown means finishing in-flight work before exiting:

import signal
import sys

def shutdown_handler(signum, frame):
    print("Received SIGTERM, shutting down gracefully...")
    # Finish current work
    # Close database connections
    # Flush logs
    sys.exit(0)

signal.signal(signal.SIGTERM, shutdown_handler)

FastAPI and Django handle this automatically when run through proper ASGI/WSGI servers. For Celery workers:

# celery worker handles SIGTERM by default:
# - Stops accepting new tasks
# - Finishes current tasks (up to timeout)
# - Exits cleanly

Fast startup matters too. Avoid heavy initialization at import time. Lazy-load connections and warm caches after the process is ready to serve.

Factor X: Dev/Prod Parity with Docker Compose

The easiest way to achieve parity:

# docker-compose.yml
services:
  web:
    build: .
    ports: ["8000:8000"]
    env_file: .env
    depends_on: [db, redis]

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: myapp
      POSTGRES_PASSWORD: devpassword
    volumes: [pgdata:/var/lib/postgresql/data]

  redis:
    image: redis:7-alpine

  worker:
    build: .
    command: celery -A tasks worker
    env_file: .env
    depends_on: [db, redis]

volumes:
  pgdata:

This replicates your production topology locally. Same database engine, same cache, same message broker.

Factor XI: Logging as Event Streams

Configure Python’s logging to write to stdout:

import logging
import sys

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)],
)

For structured logging (JSON), use python-json-logger:

from pythonjsonlogger import jsonlogger

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(jsonlogger.JsonFormatter(
    "%(asctime)s %(levelname)s %(name)s %(message)s"
))
logging.root.addHandler(handler)

JSON logs are parseable by ELK, Datadog, CloudWatch, and every modern log aggregation tool. Never write to files — let the platform handle log routing.

The Procfile Pattern

A Procfile declares your process types:

web: gunicorn myapp.wsgi:application --bind 0.0.0.0:$PORT
worker: celery -A myapp worker --loglevel=info
beat: celery -A myapp beat --loglevel=info
migrate: python manage.py migrate

Even if you don’t deploy to Heroku, the Procfile is useful documentation. Tools like honcho and foreman can run all process types locally from a single command.

Twelve-Factor Compliance Checklist

FactorPython CheckCommon Violation
CodebaseOne repo in GitMultiple apps sharing a repo without clear boundaries
Dependenciesrequirements.txt or pyproject.tomlSystem-level pip install without virtualenv
Configos.environ or pydantic-settingsHardcoded DB_HOST = "localhost" in settings
Backing servicesURL-based connectionsDirect file paths to local services
Build/release/runCI/CD pipelineSSH + manual deploy
StatelessExternal session/file storageIn-process state, local file uploads
Port bindingGunicorn/Uvicorn direct bindRelying on Apache mod_wsgi config
ConcurrencyMultiple worker typesSingle monolithic process
DisposabilitySIGTERM handling, fast startup30-second import chains, no cleanup
Dev/prod parityDocker Compose mirroring prodSQLite in dev, PostgreSQL in prod
Logsstdout/stderr onlyWriting to /var/log/myapp.log
Admin processesmanage.py commandsManual SQL on production database

Beyond Twelve: What’s Changed Since 2011

The original twelve factors predate containers, Kubernetes, and serverless. Modern additions worth considering:

  • API-first design — Your app is consumed by other services, not just browsers
  • Observability — Structured logging, metrics, and distributed tracing (OpenTelemetry)
  • Security — Secrets management via Vault or cloud secret managers, not just env vars
  • Feature flags — Decouple deployment from release

The twelve factors remain the foundation. These additions build on top without contradicting the originals.

The one thing to remember: The twelve-factor methodology in Python means validated config via pydantic-settings, stateless processes backed by external services, stdout logging, and Docker Compose for dev/prod parity — get these right and your deployment story becomes straightforward.

pythondevopsarchitecture

See Also