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
dictin 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
| Factor | Python Check | Common Violation |
|---|---|---|
| Codebase | One repo in Git | Multiple apps sharing a repo without clear boundaries |
| Dependencies | requirements.txt or pyproject.toml | System-level pip install without virtualenv |
| Config | os.environ or pydantic-settings | Hardcoded DB_HOST = "localhost" in settings |
| Backing services | URL-based connections | Direct file paths to local services |
| Build/release/run | CI/CD pipeline | SSH + manual deploy |
| Stateless | External session/file storage | In-process state, local file uploads |
| Port binding | Gunicorn/Uvicorn direct bind | Relying on Apache mod_wsgi config |
| Concurrency | Multiple worker types | Single monolithic process |
| Disposability | SIGTERM handling, fast startup | 30-second import chains, no cleanup |
| Dev/prod parity | Docker Compose mirroring prod | SQLite in dev, PostgreSQL in prod |
| Logs | stdout/stderr only | Writing to /var/log/myapp.log |
| Admin processes | manage.py commands | Manual 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.
See Also
- Python Aggregate Pattern Why grouping related objects under a single gatekeeper prevents data chaos in your Python application.
- Python Bounded Contexts Why the same word means different things in different parts of your code — and why that is perfectly fine.
- Python Bulkhead Pattern Why smart Python apps put walls between their parts — like a ship that stays afloat even with a hole in the hull.
- Python Circuit Breaker Pattern How a circuit breaker saves your app from crashing — explained with a home electrical fuse analogy.
- Python Clean Architecture Why your Python app should look like an onion — and how that saves you from painful rewrites.