The 12-Factor App (2025 Edition)¶
The Twelve-Factor App methodology provides a set of principles for building modern, cloud-native applications. Originally formulated in 2011 for Heroku applications, these principles remain foundational for building scalable, maintainable software-as-a-service applications.
This guide adapts the 12-factor methodology for 2025, incorporating modern tools and practices while preserving the core principles that make applications portable, resilient, and easy to operate.
What's Changed Since 2019?
The principles remain the same, but the tools have evolved: uv replaces pip-tools, AWS SSM replaces credstash, Fargate provides truly disposable processes, and devcontainers ensure dev/prod parity. The methodology is timeless; the implementation is modern.
Philosophy¶
The twelve-factor methodology helps you build applications that:
- Minimize time and cost for new developers joining the project
- Offer maximum portability between execution environments
- Enable continuous deployment to modern cloud platforms
- Scale up without significant changes to tooling or architecture
- Minimize divergence between development and production
These principles apply whether you're building a small internal tool or a large-scale SaaS platform. They're particularly valuable for small teams (2-5 developers) where consistency and maintainability matter more than premature optimization.
I. Codebase¶
One codebase tracked in revision control, many deploys
Principle¶
A twelve-factor app maintains a single codebase tracked in version control with a one-to-one correlation between the codebase and the app. Multiple deployments (production, staging, development) use the same codebase but may run different versions.
Modern Implementation (2025)¶
Use Git with a monorepo approach when appropriate:
graph LR
A[Single Git Repository] --> B[Development Deploy]
A --> C[Staging Deploy]
A --> D[Production Deploy]
B --> E[Feature Branch]
C --> F[main Branch]
D --> F
Key Practices:
- Single source of truth: All code lives in one repository
- Branch strategy: Use feature branches for development,
mainfor production-ready code - No distributed codebases: Multiple apps sharing code should use dependency management, not code duplication
- Monorepo considerations: For multi-service architectures, consider a monorepo with clear module boundaries
What this looks like:
your-app/
├── .git/ # Single Git repository
├── src/ # Application code
├── tests/ # Test suite
├── pyproject.toml # Dependencies
└── .devcontainer/ # Environment definition
Anti-patterns to avoid:
- Separate repositories for frontend/backend when they're tightly coupled
- Copying code between repositories instead of using proper dependencies
- Different codebases for different environments (dev vs. prod)
Monorepo vs. Microservices
A monorepo can contain multiple apps (polyrepo), but each app should still have one codebase. Don't conflate repository structure with application architecture.
Verification¶
You're following this factor if:
- You can trace any deployment back to a specific commit
- You can reproduce any environment from the same codebase
- New developers clone exactly one repository to start working
- Code sharing happens through dependencies, not duplication
II. Dependencies¶
Explicitly declare and isolate dependencies
Principle¶
A twelve-factor app never relies on implicit system-wide packages. It declares all dependencies completely and exactly via a dependency declaration manifest. It uses dependency isolation to ensure no implicit dependencies leak in from the surrounding system.
Modern Implementation (2025)¶
Use uv for dependency management with locked versions:
# pyproject.toml - Dependency declaration
[project]
name = "your-app"
version = "0.1.0"
dependencies = [
"django>=5.2,<6.0",
"djangorestframework>=3.15,<4.0",
"mysqlclient>=2.2,<3.0",
"redis>=5.0,<6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-django>=4.8",
"ruff>=0.6",
]
Lock files for reproducibility:
Key Practices:
- Explicit declaration: All dependencies in
pyproject.toml - Version pinning: Use lock files (
uv.lock) for exact reproducibility - Isolation: Use virtual environments (managed by
uv) - No system dependencies: Package everything the app needs
- Separate dev/prod: Use optional dependencies for development tools
Dependency layers:
graph TB
A[pyproject.toml] --> B[uv.lock]
B --> C[Virtual Environment]
C --> D[Application Runtime]
style A fill:#e1f5ff
style B fill:#fff9e1
style C fill:#f0f0f0
style D fill:#e8f5e9
What this looks like in practice:
# New developer setup
git clone your-repo
uv sync # Install exact versions
uv run pytest # Run in isolated environment
# Production deployment
COPY uv.lock .
RUN uv sync --frozen # Install exact versions, fail on drift
System-level dependencies:
For system packages (PostgreSQL client libraries, image processing tools), document them explicitly:
# Dockerfile - System dependencies are code
RUN apt-get update && apt-get install -y \
libpq-dev \
libxml2-dev \
&& rm -rf /var/lib/apt/lists/*
Why This Matters¶
- Reproducibility: Same versions in dev, CI, and production
- Security: Track and audit exact dependency versions
- Debugging: Know exactly what code is running
- Confidence: No "works on my machine" problems
uv Benefits
uv is 10-100x faster than pip, has better dependency resolution, and provides a single tool for dependency management. It's the modern replacement for pip-tools, poetry, and pipenv.
Verification¶
You're following this factor if:
- Running
uv syncproduces an identical environment on any machine - Your lock file is committed to version control
- You can audit exactly which version of every dependency is installed
- No developer needs to install system packages manually (beyond Docker)
III. Config¶
Store config in the environment
Principle¶
An app's config varies between deployments (staging, production, developer environments) but the code does not. Config is everything likely to vary between deploys: database credentials, API keys, hostnames for backing services.
The twelve-factor app stores config in environment variables.
Modern Implementation (2025)¶
Use AWS Systems Manager Parameter Store with environment-based configuration:
# settings/base.py - Config structure
import os
from poseidon.commons.config.ps_config import get_parameter
# Environment variables for non-sensitive config
DEBUG = os.getenv("DEBUG", "False") == "True"
ENVIRONMENT_NAME = os.getenv("ENVIRONMENT_NAME", "Development")
# SSM Parameter Store for sensitive config
SECRET_KEY = get_parameter("/app/django/secret-key")
DATABASE_PASSWORD = get_parameter("/app/database/password")
SOCIAL_AUTH_SECRET = get_parameter("/app/auth0/client-secret")
Environment-based settings pattern:
settings/
├── __init__.py # Auto-imports based on DJANGO_SETTINGS_MODULE
├── base.py # Shared settings
├── development.py # Local development
├── staging.py # Staging environment
├── production.py # Production environment
└── logging_config.py # Logging configuration
Key Practices:
- Never commit secrets: Use
.env.examplefor documentation, not actual values - Environment variables: For deployment-specific config (environment name, debug flags)
- Parameter Store: For secrets and credentials
- Local overrides: Use
.envfiles locally (gitignored) - Type conversion: Environment variables are strings; convert explicitly
Configuration hierarchy:
graph TB
A[base.py: Shared Settings] --> B[development.py]
A --> C[staging.py]
A --> D[production.py]
B --> E[.env: Local Overrides]
C --> F[SSM: Staging Secrets]
D --> G[SSM: Production Secrets]
style A fill:#e1f5ff
style B fill:#e8f5e9
style C fill:#fff9e1
style D fill:#ffebee
What this looks like:
# Development (.env file - gitignored)
DEBUG=True
DJANGO_SETTINGS_MODULE=poseidon.settings.development
DATABASE_HOST=localhost
# Staging (environment variables + SSM)
export ENVIRONMENT_NAME=Staging
export DJANGO_SETTINGS_MODULE=poseidon.settings.staging
# Secrets fetched from /staging/app/* in SSM
# Production (environment variables + SSM)
export ENVIRONMENT_NAME=Production
export DJANGO_SETTINGS_MODULE=poseidon.settings.production
# Secrets fetched from /production/app/* in SSM
SSM Parameter organization:
/production/
/app/
/django/
/secret-key
/allowed-hosts
/database/
/password
/host
/auth0/
/client-id
/client-secret
Why This Matters¶
- Security: Secrets never committed to Git
- Flexibility: Change config without code changes
- Scalability: Same code runs in all environments
- Auditability: SSM tracks who accessed which secrets when
Common Mistakes
Don't use a config.py file with environment-specific values. Don't group environments in config files (e.g., if ENV == 'production'). Each environment should be independently configurable through environment variables.
Verification¶
You're following this factor if:
- Your codebase contains zero hardcoded credentials
- You can deploy to a new environment by only changing environment variables
- Developers can run the app locally without accessing production secrets
- Your
.gitignoreexcludes.envand similar config files
IV. Backing Services¶
Treat backing services as attached resources
Principle¶
A backing service is any service the app consumes over the network as part of its normal operation: databases, message queues, SMTP services, caching systems. Twelve-factor treats these as attached resources, accessed via URL or credentials stored in config.
There should be no distinction between local and third-party services.
Modern Implementation (2025)¶
Resource abstraction through configuration:
# settings/base.py - Backing services as URLs/credentials
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"HOST": get_parameter("/app/database/host"),
"PORT": get_parameter("/app/database/port"),
"NAME": get_parameter("/app/database/name"),
"USER": get_parameter("/app/database/user"),
"PASSWORD": get_parameter("/app/database/password"),
}
}
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": get_parameter("/app/redis/url"),
}
}
EMAIL_HOST = get_parameter("/app/smtp/host")
EMAIL_PORT = get_parameter("/app/smtp/port")
Swapping backing services without code changes:
graph LR
A[Application Code] --> B[Database Interface]
B --> C[Local MySQL]
B --> D[RDS MySQL]
B --> E[Aurora MySQL]
A --> F[Cache Interface]
F --> G[Local Redis]
F --> H[ElastiCache]
style A fill:#e1f5ff
style C fill:#e8f5e9
style D fill:#e8f5e9
style E fill:#e8f5e9
style G fill:#fff9e1
style H fill:#fff9e1
Key Practices:
- URL-based addressing: Databases, cache, queues referenced by URL
- Credential injection: Connection details come from config
- Loose coupling: App doesn't know if service is local or remote
- Resource swapping: Switch services by changing config
Example: Swappable backing services
# Development: Local services
DATABASES["default"]["HOST"] = "localhost"
CACHES["default"]["LOCATION"] = "redis://localhost:6379/1"
EMAIL_HOST = "mailhog" # Local mail testing
# Production: AWS services
DATABASES["default"]["HOST"] = "prod-db.us-east-1.rds.amazonaws.com"
CACHES["default"]["LOCATION"] = "redis://prod-cache.abc123.ng.0001.use1.cache.amazonaws.com:6379"
EMAIL_HOST = "email-smtp.us-east-1.amazonaws.com"
Integration patterns:
# Abstraction for external services
class IntegrationClient:
"""Base client for external service integrations."""
def __init__(self):
self.base_url = os.getenv("INTEGRATION_BASE_URL")
self.api_key = get_parameter("/app/integration/api-key")
def make_request(self, endpoint):
# Service is accessed via URL, not hardcoded
return requests.get(f"{self.base_url}/{endpoint}")
Why This Matters¶
- Flexibility: Swap databases without code changes
- Testing: Use local services in development, cloud services in production
- Disaster recovery: Quickly point to backup resources
- Cost optimization: Use different service tiers per environment
Multi-Database Example
Django's database routing allows treating multiple databases as attached resources, each configured independently through environment variables.
Verification¶
You're following this factor if:
- You can swap a database to a different server by changing config
- Local development uses the same service types as production (MySQL, Redis)
- Service outages are handled by updating resource URLs, not code
- No service endpoints are hardcoded in your application
V. Build, Release, Run¶
Strictly separate build and run stages
Principle¶
The twelve-factor app strictly separates the build, release, and run stages. A codebase is transformed into a deploy through three stages:
- Build: Convert code into an executable bundle
- Release: Combine build with config for a specific environment
- Run: Execute the release in the execution environment
Modern Implementation (2025)¶
CI/CD pipeline with distinct stages:
graph LR
A[Code Commit] --> B[Build Stage]
B --> C[Container Image]
C --> D[Release Stage]
D --> E[Tagged Release]
E --> F[Run Stage: Dev]
E --> G[Run Stage: Staging]
E --> H[Run Stage: Production]
I[Config: Dev] --> F
J[Config: Staging] --> G
K[Config: Production] --> H
style B fill:#e1f5ff
style D fill:#fff9e1
style F fill:#e8f5e9
style G fill:#e8f5e9
style H fill:#ffebee
Build stage (GitHub Actions):
# .github/workflows/build.yml
name: Build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build container
run: |
docker build \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg VCS_REF=${{ github.sha }} \
-t app:${{ github.sha }} .
- name: Run tests
run: docker run app:${{ github.sha }} pytest
- name: Push to registry
run: |
docker tag app:${{ github.sha }} registry/app:${{ github.sha }}
docker push registry/app:${{ github.sha }}
Release stage (Tagging):
# Create release by tagging built artifact
docker tag registry/app:abc123 registry/app:release-v1.2.3
# Release is immutable combination of:
# - Build artifact (image abc123)
# - Version identifier (v1.2.3)
# - Deployment metadata
Run stage (ECS Task Definition):
{
"family": "your-app",
"containerDefinitions": [{
"name": "app",
"image": "registry/app:release-v1.2.3",
"environment": [
{"name": "DJANGO_SETTINGS_MODULE", "value": "app.settings.production"}
],
"secrets": [
{"name": "SECRET_KEY", "valueFrom": "/production/app/django/secret-key"}
]
}]
}
Key Practices:
- Immutable builds: Once built, artifacts never change
- Versioned releases: Every release has a unique identifier
- Config separation: Environment-specific config applied at runtime
- Rollback capability: Re-run previous releases without rebuilding
- Audit trail: Track which build is running where
Build process:
# Dockerfile - Build stage
FROM python:3.13-slim AS builder
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen
COPY . .
RUN uv run python manage.py collectstatic --noinput
# Runtime image (release)
FROM python:3.13-slim
COPY --from=builder /app /app
WORKDIR /app
CMD ["uv", "run", "gunicorn", "config.wsgi"]
Why This Matters¶
- Reproducibility: Exact same build runs in all environments
- Speed: Builds happen once, run many times
- Safety: Can't accidentally run untested code
- Rollbacks: Quickly revert to previous release
Common Mistakes
Don't build different artifacts for different environments. Don't run git pull on production servers. Don't install dependencies at runtime. Build once, configure per environment, run everywhere.
Verification¶
You're following this factor if:
- You can identify exactly which Git commit is running in production
- Rollback means changing which release is running, not rebuilding
- Builds happen in CI, not on deployment servers
- Configuration is the only difference between environments
VI. Processes¶
Execute the app as one or more stateless processes
Principle¶
Twelve-factor processes are stateless and share-nothing. Any persistent state must be stored in a stateful backing service (typically a database). The memory space or filesystem of the process can be used as a brief, single-transaction cache.
Modern Implementation (2025)¶
Stateless Django processes on AWS Fargate:
graph TB
A[Load Balancer] --> B[Fargate Task 1]
A --> C[Fargate Task 2]
A --> D[Fargate Task 3]
B --> E[RDS Database]
C --> E
D --> E
B --> F[ElastiCache Redis]
C --> F
D --> F
style B fill:#e1f5ff
style C fill:#e1f5ff
style D fill:#e1f5ff
style E fill:#e8f5e9
style F fill:#fff9e1
Key Practices:
- No local state: Session data in Redis, files in S3
- No sticky sessions: Any process can handle any request
- Ephemeral filesystem: Treat container storage as temporary
- Horizontal scalability: Add processes, not bigger processes
Session management:
# settings/production.py - Sessions in Redis, not memory
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default" # Redis
# Never store in local filesystem
# SESSION_ENGINE = "django.contrib.sessions.backends.file" # ❌ Wrong
File handling:
# Store uploads in S3, not local filesystem
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
AWS_STORAGE_BUCKET_NAME = get_parameter("/app/s3/bucket")
# Bad: Storing in process memory or local disk
# def upload_file(file):
# with open(f"/tmp/{file.name}", "wb") as f: # ❌ Won't persist
# f.write(file.read())
Background tasks:
# Use Celery for async work, not in-process threads
from celery import shared_task
@shared_task
def process_upload(file_id):
# Runs in separate worker process
# State stored in database, not memory
file = File.objects.get(id=file_id)
result = process(file)
file.status = "completed"
file.save()
Process types:
# docker-compose.yml - Multiple process types, all stateless
services:
web:
image: app:latest
command: gunicorn config.wsgi
environment:
- DJANGO_SETTINGS_MODULE=config.settings.production
worker:
image: app:latest # Same build, different process type
command: celery -A config worker
environment:
- DJANGO_SETTINGS_MODULE=config.settings.production
beat:
image: app:latest # Same build, different process type
command: celery -A config beat
environment:
- DJANGO_SETTINGS_MODULE=config.settings.production
Why This Matters¶
- Scalability: Add/remove processes freely without coordination
- Reliability: Process crashes don't lose data
- Deployment: Replace processes without downtime
- Resource efficiency: No state means simpler, faster processes
Fargate Benefits
AWS Fargate provides truly stateless, disposable processes. No server management, automatic scaling, and every task starts fresh. Perfect for twelve-factor applications.
Verification¶
You're following this factor if:
- You can kill any process without data loss
- Adding more processes increases capacity linearly
- No process stores anything on its local filesystem permanently
- Session state persists across process restarts
VII. Port Binding¶
Export services via port binding
Principle¶
A twelve-factor app is completely self-contained and exports HTTP as a service by binding to a port. The app doesn't rely on runtime injection of a webserver into the execution environment; instead, the web app exports HTTP by binding to a port and listening to requests.
Modern Implementation (2025)¶
Django with Gunicorn exposing HTTP:
# config/wsgi.py - WSGI application
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
application = get_wsgi_application()
Port binding configuration:
# Dockerfile - Self-contained HTTP server
FROM python:3.13-slim
WORKDIR /app
COPY . .
# Expose port (documentation only)
EXPOSE 8000
# Bind to port and serve HTTP
CMD ["gunicorn", \
"--bind", "0.0.0.0:8000", \
"--workers", "4", \
"--timeout", "60", \
"config.wsgi:application"]
Key Practices:
- Self-contained server: App includes its own HTTP server (Gunicorn)
- Port configuration: Port number comes from environment
- No external server: Don't rely on Apache/nginx being pre-configured
- HTTP export: App speaks HTTP natively
Port binding hierarchy:
graph TB
A[ECS Task] --> B[Container]
B --> C[Gunicorn :8000]
C --> D[WSGI App]
E[Load Balancer :443] --> A
style A fill:#e1f5ff
style C fill:#e8f5e9
style D fill:#fff9e1
style E fill:#ffebee
Environment-based port binding:
# Runtime port configuration
import os
# Port from environment, default to 8000
PORT = int(os.getenv("PORT", "8000"))
# Gunicorn binds to this port
bind = f"0.0.0.0:{PORT}"
ECS task definition:
{
"containerDefinitions": [{
"name": "web",
"portMappings": [{
"containerPort": 8000,
"protocol": "tcp"
}],
"environment": [
{"name": "PORT", "value": "8000"}
]
}]
}
Local development:
# App exports HTTP on port 8000
uv run gunicorn --bind 0.0.0.0:8000 config.wsgi
# Access at http://localhost:8000
# No nginx, no Apache - just the app
Why This Matters¶
- Simplicity: No complex server configuration required
- Portability: App runs the same locally and in production
- Routing layer: Load balancers/proxies are added on top, not required
- Development: Run app exactly as it runs in production
Routing Layer
In production, you'll typically add a routing layer (ALB, nginx) for HTTPS termination, load balancing, and static files. But the app itself is independent of these.
Verification¶
You're following this factor if:
- You can run your app with just
gunicorn config.wsgi - The app doesn't require pre-configured Apache/nginx to run
- Port number is configurable via environment variable
- App serves HTTP directly, not through a separate web server
VIII. Concurrency¶
Scale out via the process model
Principle¶
In a twelve-factor app, processes are first-class citizens. The process model shines when it's time to scale out. The share-nothing, horizontally partitionable nature of twelve-factor app processes means adding more concurrency is simple and reliable.
Modern Implementation (2025)¶
Process-based concurrency with ECS:
graph TB
subgraph "Web Tier"
W1[Web Process 1]
W2[Web Process 2]
W3[Web Process 3]
end
subgraph "Worker Tier"
WK1[Celery Worker 1]
WK2[Celery Worker 2]
end
subgraph "Scheduler"
B[Celery Beat]
end
LB[Load Balancer] --> W1
LB --> W2
LB --> W3
W1 --> Q[Task Queue]
W2 --> Q
W3 --> Q
Q --> WK1
Q --> WK2
B --> Q
style LB fill:#ffebee
style W1 fill:#e1f5ff
style W2 fill:#e1f5ff
style W3 fill:#e1f5ff
style WK1 fill:#e8f5e9
style WK2 fill:#e8f5e9
style B fill:#fff9e1
Key Practices:
- Process types: Different workload types (web, worker, scheduler)
- Horizontal scaling: Add more processes, not threads
- Independent scaling: Scale each process type independently
- Process formation: Mix and match process types per environment
ECS service configuration:
{
"services": [
{
"serviceName": "web",
"taskDefinition": "app:web",
"desiredCount": 3, // Scale by adding processes
"deploymentConfiguration": {
"maximumPercent": 200,
"minimumHealthyPercent": 100
}
},
{
"serviceName": "worker",
"taskDefinition": "app:worker",
"desiredCount": 2 // Scale independently
}
]
}
Gunicorn worker configuration:
# gunicorn.conf.py
import multiprocessing
# Workers = (CPU cores * 2) + 1
workers = multiprocessing.cpu_count() * 2 + 1
# Worker class for async I/O
worker_class = "gevent"
# Each worker handles requests independently
max_requests = 1000
max_requests_jitter = 50
Celery worker concurrency:
# Celery worker with process-based concurrency
celery -A config worker \
--concurrency=4 \
--max-tasks-per-child=1000
# Scale by adding more workers (processes)
# Not by increasing thread count per worker
Auto-scaling policies:
{
"targetTrackingScalingPolicyConfiguration": {
"targetValue": 75.0,
"predefinedMetricSpecification": {
"predefinedMetricType": "ECSServiceAverageCPUUtilization"
}
}
}
Process formation matrix:
| Environment | Web Processes | Worker Processes | Beat Processes |
|---|---|---|---|
| Development | 1 | 1 | 1 |
| Staging | 2 | 1 | 1 |
| Production | 5 | 3 | 1 |
Why This Matters¶
- Elasticity: Respond to load by adding/removing processes
- Resource optimization: Right-size each process type
- Fault tolerance: Process crashes don't affect others
- Simplicity: No complex thread/connection pooling logic
Don't Use Threading for Scaling
Threads add complexity and concurrency bugs. Use multiple processes instead. Modern container orchestration makes this trivial.
Verification¶
You're following this factor if:
- You scale by changing process count, not process size
- Different workload types run in different process types
- You can independently scale web and worker processes
- Process count varies by environment based on load
IX. Disposability¶
Maximize robustness with fast startup and graceful shutdown
Principle¶
Twelve-factor app processes are disposable, meaning they can be started or stopped at a moment's notice. This facilitates fast elastic scaling, rapid deployment of code or config changes, and robustness of production deploys.
Modern Implementation (2025)¶
Fast startup through optimized containers:
# Dockerfile - Optimized for fast startup
FROM python:3.13-slim
# Pre-install dependencies (cached layer)
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen
# Add application code (changes frequently)
COPY . .
# Minimize startup work
RUN uv run python manage.py collectstatic --noinput
# Ready to handle requests in <2 seconds
CMD ["gunicorn", "config.wsgi"]
Graceful shutdown handling:
# config/wsgi.py - Graceful shutdown
import signal
import sys
import logging
logger = logging.getLogger(__name__)
def handle_shutdown(signum, frame):
"""Handle graceful shutdown on SIGTERM."""
logger.info("Received SIGTERM, shutting down gracefully...")
# Finish current requests, close connections
sys.exit(0)
signal.signal(signal.SIGTERM, handle_shutdown)
Gunicorn graceful shutdown:
# gunicorn.conf.py
# Graceful timeout for shutting down workers
graceful_timeout = 30
# Worker timeout
timeout = 60
# On worker shutdown, finish in-flight requests
def on_exit(server):
logger.info("Gunicorn master shutting down")
def worker_exit(server, worker):
logger.info(f"Worker {worker.pid} shutting down")
Key Practices:
- Fast startup: Minimize initialization time (<5 seconds ideal)
- Graceful shutdown: Handle SIGTERM, finish in-flight requests
- Crash recovery: Process crashes don't corrupt state
- Ready for termination: Can be killed at any moment safely
Health checks:
# Health check endpoint for orchestrator
from django.http import JsonResponse
from django.db import connection
def health_check(request):
"""Lightweight health check for load balancer."""
try:
# Verify database connectivity
connection.ensure_connection()
return JsonResponse({"status": "healthy"})
except Exception as e:
return JsonResponse(
{"status": "unhealthy", "error": str(e)},
status=503
)
ECS health check configuration:
{
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:8000/health/ || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 60
}
}
Deployment strategy:
graph LR
A[Old Task Running] --> B[New Task Starting]
B --> C[Health Check Passes]
C --> D[Traffic Shifts to New]
D --> E[Old Task Receives SIGTERM]
E --> F[Old Task Drains Requests]
F --> G[Old Task Exits]
style A fill:#ffebee
style B fill:#e1f5ff
style C fill:#e8f5e9
style D fill:#e8f5e9
style E fill:#fff9e1
style G fill:#f0f0f0
Celery graceful shutdown:
# Celery worker configuration
from celery.signals import worker_shutdown
@worker_shutdown.connect
def graceful_shutdown(**kwargs):
"""Finish current tasks before shutting down."""
logger.info("Worker shutting down gracefully")
# Celery automatically waits for tasks to complete
Why This Matters¶
- Zero-downtime deployments: Replace processes without user impact
- Fast recovery: Crashed processes restart in seconds
- Elastic scaling: Spin up new processes quickly for traffic spikes
- Robustness: Processes can be killed without data loss
Fargate Disposability
Fargate tasks are designed to be disposable. Every deployment creates fresh tasks. No state persists between tasks. This perfectly aligns with twelve-factor principles.
Verification¶
You're following this factor if:
- Your app starts and is ready to serve traffic in under 10 seconds
- You can kill processes with SIGTERM without data loss
- Deployments happen without downtime
- Process crashes are recovered automatically by the orchestrator
X. Dev/Prod Parity¶
Keep development, staging, and production as similar as possible
Principle¶
Historically there have been gaps between development and production: time gap (code takes days/weeks to deploy), personnel gap (developers write code, ops deploy it), and tools gap (different databases/services). Twelve-factor keeps dev/prod parity small.
Modern Implementation (2025)¶
Devcontainers for environment parity:
// .devcontainer/devcontainer.json
{
"name": "Django Development",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/app",
// Same base image as production
"build": {
"dockerfile": "../Dockerfile",
"target": "development"
},
// Same Python version as production
"features": {
"ghcr.io/devcontainers/features/python:1": {
"version": "3.13"
}
}
}
Minimize the gaps:
graph TB
subgraph "Traditional Approach"
T1[Dev: SQLite]
T2[Dev: Local files]
T3[Prod: PostgreSQL]
T4[Prod: S3]
end
subgraph "Twelve-Factor Approach"
F1[Dev: MySQL in Docker]
F2[Dev: LocalStack S3]
F3[Prod: RDS MySQL]
F4[Prod: AWS S3]
F1 -.->|Same interface| F3
F2 -.->|Same interface| F4
end
style T1 fill:#ffebee
style T2 fill:#ffebee
style F1 fill:#e8f5e9
style F2 fill:#e8f5e9
style F3 fill:#e8f5e9
style F4 fill:#e8f5e9
Key Practices:
- Same backing services: MySQL in dev and prod, not SQLite vs. MySQL
- Same Python version: 3.13 everywhere
- Same dependencies: Exact versions from
uv.lock - Continuous deployment: Small time gap between dev and prod
Docker Compose for local services:
# docker-compose.yml - Production-like services locally
services:
app:
build: .
environment:
- DJANGO_SETTINGS_MODULE=config.settings.development
depends_on:
- db
- redis
- localstack
db:
image: mysql:8.0 # Same major version as RDS
environment:
MYSQL_DATABASE: app
MYSQL_ROOT_PASSWORD: dev
redis:
image: redis:7-alpine # Same major version as ElastiCache
localstack:
image: localstack/localstack # Mock AWS services
environment:
- SERVICES=s3,ssm,ses
Gap comparison:
| Gap | Traditional | Twelve-Factor |
|---|---|---|
| Time | Weeks between dev and deploy | Hours or continuous |
| Personnel | Devs write, ops deploy | Devs deploy their own code |
| Tools | SQLite dev, Postgres prod | MySQL dev, MySQL prod |
| Environment | Local Python install | Devcontainer matching prod |
| Dependencies | Approximate versions | Exact locked versions |
Settings parity:
# settings/base.py - Shared configuration
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql", # Same in dev and prod
"OPTIONS": {
"charset": "utf8mb4",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'"
}
}
}
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache", # Same in dev and prod
}
}
Only differences should be in deployment-specific settings:
# settings/development.py
DATABASES["default"]["HOST"] = "db" # Docker Compose service name
CACHES["default"]["LOCATION"] = "redis://redis:6379/1"
# settings/production.py
DATABASES["default"]["HOST"] = get_parameter("/app/database/host") # RDS endpoint
CACHES["default"]["LOCATION"] = get_parameter("/app/redis/url") # ElastiCache endpoint
Why This Matters¶
- Fewer bugs: Issues caught in dev, not prod
- Confidence: What works locally works in production
- Faster debugging: Can reproduce prod issues locally
- Continuous deployment: Small changes deploy frequently
Avoid Adapters
Don't use abstraction layers to support different backing services in different environments. Use the same services everywhere. Docker makes this trivial.
Verification¶
You're following this factor if:
- Same database type and version in dev and prod
- Same Python version in dev and prod
- Code deploys to production within hours of being written
- Developers can reproduce production issues in their local environment
XI. Logs¶
Treat logs as event streams
Principle¶
A twelve-factor app never concerns itself with routing or storage of its output stream. It should not attempt to write to or manage logfiles. Instead, each running process writes its event stream, unbuffered, to stdout. In development, the developer views this stream in the terminal. In production, the execution environment captures, routes, and analyzes the stream.
Modern Implementation (2025)¶
Structured logging to stdout:
# settings/base.py - Logging configuration
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(asctime)s %(name)s %(levelname)s %(message)s"
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "json"
}
},
"root": {
"handlers": ["console"],
"level": "INFO"
}
}
Application logging:
import logging
logger = logging.getLogger(__name__)
# Structured log with context
logger.info(
"User login successful",
extra={
"user_id": user.id,
"email": user.email,
"ip_address": request.META.get("REMOTE_ADDR"),
"request_id": request.id
}
)
Log aggregation architecture:
graph LR
A[App Process] -->|stdout| B[Container]
B -->|Log Driver| C[CloudWatch Logs]
C --> D[Log Insights]
C --> E[Alarms]
C --> F[Sentry for Errors]
style A fill:#e1f5ff
style B fill:#e8f5e9
style C fill:#fff9e1
style D fill:#ffebee
style E fill:#ffebee
style F fill:#ffebee
Key Practices:
- Write to stdout/stderr: No log files on disk
- Structured logging: JSON format for machine parsing
- Context enrichment: Include request ID, user ID, tenant ID
- Let platform handle: CloudWatch captures and stores logs
- Centralized analysis: Query logs across all processes
CloudWatch Logs integration (ECS):
{
"containerDefinitions": [{
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/your-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "web"
}
}
}]
}
Development logging:
# settings/development.py - Human-readable in development
LOGGING["formatters"]["console"] = {
"format": "%(levelname)s %(asctime)s %(name)s %(message)s"
}
# Production uses JSON formatter for structured logs
Log levels:
# Use appropriate log levels
logger.debug("Detailed diagnostic info") # Development only
logger.info("Normal operations") # Expected events
logger.warning("Unexpected but handled") # Degraded state
logger.error("Error occurred") # Failures
logger.critical("System unstable") # Immediate attention needed
Never log to files:
# ❌ Wrong - don't manage log files
# handler = logging.FileHandler("/var/log/app.log")
# ✅ Right - write to stdout
handler = logging.StreamHandler(sys.stdout)
Why This Matters¶
- Simplicity: App doesn't manage log files, rotation, retention
- Flexibility: Change log routing without code changes
- Scalability: Logs from all processes aggregated automatically
- Analysis: Query across all processes in one place
CloudWatch Log Insights
Use CloudWatch Log Insights to query structured logs: fields @timestamp, @message | filter user_id = 123 | sort @timestamp desc | limit 20
Verification¶
You're following this factor if:
- Your app writes all logs to stdout/stderr
- No log files exist in your container filesystem
- You can query logs from all processes in one place
- Log format is consistent and machine-parseable
XII. Admin Processes¶
Run admin/management tasks as one-off processes
Principle¶
Run admin/management tasks as one-off processes in an identical environment as the app's regular long-running processes. Admin code must ship with application code to avoid synchronization issues.
Modern Implementation (2025)¶
Django management commands:
# management/commands/migrate_data.py
from django.core.management.base import BaseCommand
from app.models import User
class Command(BaseCommand):
"""One-time data migration."""
def handle(self, *args, **options):
# Runs in same environment as web processes
# Same database, same Python version, same dependencies
users = User.objects.filter(status="pending")
for user in users:
user.migrate_to_new_schema()
user.save()
self.stdout.write(
self.style.SUCCESS(f"Migrated {users.count()} users")
)
Running admin processes:
graph LR
A[Same Codebase] --> B[Web Process]
A --> C[Worker Process]
A --> D[Admin Process]
B --> E[Same Database]
C --> E
D --> E
B --> F[Same Redis]
C --> F
D --> F
style A fill:#e1f5ff
style B fill:#e8f5e9
style C fill:#e8f5e9
style D fill:#fff9e1
Key Practices:
- Same codebase: Admin scripts in version control
- Same environment: Run with same Python, dependencies, config
- Django management commands: Standard interface for admin tasks
- One-off execution: Start, run, terminate
ECS one-off task execution:
# Run Django management command as ECS task
aws ecs run-task \
--cluster production-cluster \
--task-definition app:latest \
--overrides '{
"containerOverrides": [{
"name": "app",
"command": ["python", "manage.py", "migrate_data"]
}]
}'
Common admin processes:
# Database migrations
python manage.py migrate
# Create superuser
python manage.py createsuperuser
# Import data
python manage.py import_users --file users.csv
# Generate reports
python manage.py generate_monthly_report --month 2025-01
# Cleanup tasks
python manage.py cleanup_old_sessions
Admin command best practices:
from django.core.management.base import BaseCommand
from django.db import transaction
class Command(BaseCommand):
help = "Import users from CSV file"
def add_arguments(self, parser):
parser.add_argument("--file", required=True)
parser.add_argument("--dry-run", action="store_true")
@transaction.atomic
def handle(self, *args, **options):
"""Run admin task with same guarantees as web code."""
file_path = options["file"]
dry_run = options["dry_run"]
# Use same models, same business logic
users = self.import_from_csv(file_path)
if dry_run:
self.stdout.write("Dry run - would import {len(users)} users")
raise transaction.Rollback()
self.stdout.write(
self.style.SUCCESS(f"Imported {len(users)} users")
)
Scheduled admin processes (Celery Beat):
# tasks.py - Scheduled admin tasks
from celery import shared_task
@shared_task
def cleanup_old_sessions():
"""Run nightly cleanup as scheduled admin task."""
from django.core.management import call_command
call_command("clearsessions")
Interactive admin processes:
# Django shell in production (same environment)
aws ecs run-task \
--cluster production-cluster \
--task-definition app:latest \
--overrides '{
"containerOverrides": [{
"name": "app",
"command": ["python", "manage.py", "shell"]
}]
}'
Why This Matters¶
- No drift: Admin scripts use same code as application
- Consistency: Same environment, dependencies, and configuration
- Version control: Admin code tracked with application code
- Auditable: Admin tasks logged like any other process
Avoid SSH-ing to Production
Don't SSH to production servers to run scripts. Run admin commands through the same orchestration platform (ECS, Kubernetes) that runs your app.
Verification¶
You're following this factor if:
- All admin scripts are Django management commands in your codebase
- Admin processes run with the same Docker image as web processes
- You can run admin tasks without SSH access to servers
- Admin commands use the same database connections and config as web code
Summary¶
The twelve factors work together to create applications that are:
| Factor | Benefit |
|---|---|
| I. Codebase | Trackability: Know exactly what code is running where |
| II. Dependencies | Reproducibility: Identical environments everywhere |
| III. Config | Flexibility: Change environment without code changes |
| IV. Backing Services | Portability: Swap services without code changes |
| V. Build, Release, Run | Safety: Tested code, immutable releases |
| VI. Processes | Scalability: Add capacity by adding processes |
| VII. Port Binding | Simplicity: Self-contained, no external dependencies |
| VIII. Concurrency | Performance: Right-sized processes for each workload |
| IX. Disposability | Robustness: Fast startup, graceful shutdown |
| X. Dev/Prod Parity | Confidence: What works in dev works in prod |
| XI. Logs | Observability: Centralized, queryable event streams |
| XII. Admin Processes | Consistency: Admin tasks use same environment as app |
Modern Stack Checklist¶
Verify your application follows twelve-factor principles:
- Single Git repository for the entire application
- Dependencies managed with
uvand lock files - Configuration via environment variables and SSM Parameter Store
- Backing services (MySQL, Redis) configured via URLs
- CI/CD pipeline with separate build, release, and run stages
- Stateless processes with session state in Redis
- Self-contained HTTP server (Gunicorn) binding to ports
- Process-based concurrency with ECS auto-scaling
- Fast startup (<10s) and graceful shutdown (SIGTERM handling)
- Devcontainers providing dev/prod parity
- Structured logs to stdout, aggregated in CloudWatch
- Django management commands for admin tasks
Next Steps¶
- Apply to your project: Review each factor against your current architecture
- Start small: Pick one factor and improve it before moving to the next
- Document decisions: Create ADRs for significant architectural changes
- Automate: Use CI/CD to enforce twelve-factor principles
- Measure: Track deployment frequency, startup time, and error rates
Further Reading
- Original 12-Factor App - The original methodology
- Beyond the 12-Factor App - Modern additions to the methodology
- Configuration Management - Deep dive on config management
- AWS ECS Deployment - Implementing twelve-factor on AWS