Skip to content

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, main for 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:

# Generate lock file with exact versions
uv lock

# Install from lock file
uv sync

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 sync produces 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.example for documentation, not actual values
  • Environment variables: For deployment-specific config (environment name, debug flags)
  • Parameter Store: For secrets and credentials
  • Local overrides: Use .env files 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 .gitignore excludes .env and 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:

  1. Build: Convert code into an executable bundle
  2. Release: Combine build with config for a specific environment
  3. 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 uv and 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