Django Production Dockerfile: Multi-Stage Build with Gunicorn ASGI
python manage.py runserveris for development only. Running it in production is like driving a toy car on a highway. This module teaches you to build the real thing.
What You Will Build
By the end of this tutorial, you will have a production-grade Django container that:
- Uses multi-stage builds to shrink from 1.2GB to under 200MB
- Runs Gunicorn with Uvicorn workers for ASGI async support
- Operates as a non-root user for security isolation
- Includes health checks that Docker can monitor
- Serves static and media files correctly behind a reverse proxy
- Handles environment configuration via
.envfiles (never hardcoded)
The Toy Car on a Highway
Running Django's development server in production is one of the most dangerous mistakes a developer can make. Here is why.
What runserver Actually Does
1python manage.py runserver 0.0.0.0:8000
The Django development server:
- Single-threaded — handles one request at a time
- No process management — crashes on unhandled exceptions
- Auto-reloads on code changes — wastes CPU watching files
- Serves static files inefficiently — reads from disk on every request
- Exposes debug information — stack traces leak source code
- No request timeouts — a slow query hangs the entire server
Django's own documentation states: "DO NOT USE THIS SERVER IN A PRODUCTION SETTING." This is not a suggestion. It is a warning.
What Gunicorn Does Instead
Gunicorn (Green Unicorn) is a production-grade WSGI/ASGI server. It:
- Spawns multiple worker processes that handle requests concurrently
- Restarts crashed workers automatically
- Load balances requests across all workers
- Gracefully reloads without dropping connections
- Handles signals (SIGTERM, SIGUSR1) for orchestrator integration
- Supports ASGI via Uvicorn workers for async views and WebSockets
Before vs After: The Full Comparison
| Aspect | Development (runserver) | Production (Gunicorn + Docker) |
|---|---|---|
| Server | python manage.py runserver | gunicorn --workers 4 --bind 0.0.0.0:8000 |
| Image size | 1.2GB+ (includes gcc, git, dev tools) | ~180MB (runtime libraries only) |
| User | root (UID 0) | dedicated django user (UID 1000) |
| Health checks | None | HTTP endpoint + Docker HEALTHCHECK |
| Secrets | Hardcoded in settings.py | Environment variables / Docker secrets |
| Static files | Served by Django (slow) | Collected at build, served by Nginx |
| Concurrent requests | 1 at a time | 4+ workers = 4+ concurrent |
| Crash recovery | Server dies, manual restart | Worker auto-restarts via Gunicorn master |
| Request timeout | None (hangs forever) | Configurable worker timeouts |
| Process isolation | None | Each worker is a separate OS process |
Multi-Stage Dockerfile Explained Line by Line
This is the complete production Dockerfile. Every line has a purpose.
1# ============================================ 2# STAGE 1: Builder 3# This stage compiles everything. It is large and temporary. 4# Only the compiled output moves to the next stage. 5# ============================================ 6FROM python:3.12.4-slim AS builder 7 8# Prevent Python from writing .pyc files and buffering stdout 9ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 10 11WORKDIR /app 12 13# Install system build dependencies. 14# These are required to compile Python packages with C extensions 15# (like psycopg2, cryptography, Pillow) but are NOT needed at runtime. 16RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev && rm -rf /var/lib/apt/lists/* 17 18# Create a virtual environment. 19# This isolates Python packages from the system Python. 20RUN python -m venv /opt/venv 21ENV PATH="/opt/venv/bin:$PATH" 22 23# Copy requirements FIRST — this is the layer caching optimization. 24# If requirements.txt hasn't changed, Docker skips the pip install step. 25COPY requirements.txt . 26RUN pip install --upgrade pip && pip install -r requirements.txt 27 28# ============================================ 29# STAGE 2: Production 30# This stage contains ONLY what is needed to run the application. 31# No compilers. No dev tools. No build artifacts. 32# ============================================ 33FROM python:3.12.4-slim AS production 34 35# Set runtime environment variables 36ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH="/opt/venv/bin:$PATH" PYTHONFAULTHANDLER=1 PYTHONHASHSEED=random 37 38# Install ONLY runtime dependencies. 39# libpq5 is needed for psycopg2 to connect to PostgreSQL. 40# curl is needed for the health check. 41RUN apt-get update && apt-get install -y --no-install-recommends libpq5 curl && rm -rf /var/lib/apt/lists/* 42 43WORKDIR /app 44 45# Copy the virtual environment from the builder stage. 46# This contains all pip-installed packages, pre-compiled and ready. 47COPY /opt/venv /opt/venv 48 49# Copy application code and set ownership. 50COPY . . 51 52# Create a non-root user and group. 53# The -r flag creates a system user (no home directory, no login shell). 54# UID 1000 is a standard convention for application users. 55RUN groupadd -r django && useradd -r -g django django 56 57# Collect static files so they are available at runtime. 58# This runs during the image build, not at container startup. 59RUN python manage.py collectstatic --noinput 60 61# Switch to the non-root user. 62# Every command after this runs as 'django', not root. 63USER django 64 65# Document which port the container listens on. 66# This is metadata — it does not actually publish the port. 67EXPOSE 8000 68 69# Health check: Docker verifies the app is actually responsive. 70# If this fails 3 times in a row, Docker marks the container unhealthy. 71HEALTHCHECK CMD curl -f http://localhost:8000/health/ || exit 1 72 73# The command that runs when the container starts. 74# --workers 4: spawn 4 worker processes 75# --worker-class uvicorn.workers.UvicornWorker: enable ASGI (async) support 76# --bind 0.0.0.0:8000: listen on all interfaces inside the container 77# --worker-tmp-dir /dev/shm: use shared memory for temp files (faster than disk) 78# --access-logfile -: log to stdout (Docker captures this) 79CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "--worker-class", "uvicorn.workers.UvicornWorker", "--worker-tmp-dir", "/dev/shm", "--access-logfile", "-", "--error-logfile", "-", "--capture-output", "--enable-stdio-inheritance", "deploymart.asgi:application"]
Why Two Stages?
The builder stage is like a construction site. It has cranes, cement mixers, and scaffolding. You need them to build the building, but you do not want them in the finished office.
| Stage | Size | Contents | Purpose |
|---|---|---|---|
| Builder | ~800MB | Python + gcc + build-essential + libpq-dev + venv | Compile Python packages |
| Production | ~180MB | Python + libpq5 + venv (copied) + app code | Run the application |
The production image is 4x smaller, starts 5x faster, and has zero compiler tools that attackers could exploit.
Gunicorn Worker Architecture
How Requests Flow
- Nginx receives an HTTP request from the internet
- Nginx forwards it to Gunicorn on port 8000
- Gunicorn Master receives the request and assigns it to an idle worker
- Gunicorn Worker processes the request through Django's ASGI application
- Django executes the view, queries PostgreSQL via the ORM, returns a response
- Worker sends the response back to Nginx
- Nginx sends the response to the client
The Master Never Serves Requests
The master process has one job: manage workers. It:
- Spawns workers on startup
- Restarts workers that crash
- Gracefully reloads workers when signaled (zero-downtime deploys)
- Handles SIGTERM from Docker (clean shutdown)
If the master process dies, all workers die. This is why Docker's restart: unless-stopped policy is critical.
Worker Formula
Gunicorn's official recommendation:
workers = (2 x CPU cores) + 1
| CPU Cores | Workers | Memory per Worker | Total Memory |
|---|---|---|---|
| 1 | 3 | ~50MB | ~150MB |
| 2 | 5 | ~50MB | ~250MB |
| 4 | 9 | ~50MB | ~450MB |
| 8 | 17 | ~50MB | ~850MB |
Adjust based on memory: If your server has 2GB RAM and each worker uses 100MB, limit workers to 15 (leaving headroom for PostgreSQL, Redis, and Nginx).
WSGI vs ASGI Workers
| Worker Class | Use Case | Django Feature Support |
|---|---|---|
sync (default) | Traditional synchronous views | Views, ORM, templates, admin |
gevent | Green-threaded async I/O | Views with external API calls |
uvicorn.workers.UvicornWorker | Full ASGI async | Async views, WebSockets, HTTP/2, SSE |
Recommendation: Use UvicornWorker even if you do not have async views today. It provides the foundation for future async features and has no performance penalty for sync views.
Complete Production Requirements
1# requirements.txt (production only) 2Django==5.0.6 3djangorestframework==3.15.1 4django-cors-headers==4.3.1 5django-redis==5.4.0 6psycopg2-binary==2.9.9 7gunicorn==23.0.0 8uvicorn[standard]==0.30.1 9python-dotenv==1.0.1 10whitenoise==6.6.0
1# requirements-dev.txt (development extras) 2-r requirements.txt 3pytest==8.2.0 4pytest-django==4.8.0 5pytest-cov==5.0.0 6black==24.4.2 7isort==5.13.2 8flake8==7.0.0 9mypy==1.10.0 10django-debug-toolbar==4.4.2
Never install dev dependencies in production. pytest had a CVE in 2022. django-debug-toolbar exposes internal state. Each extra package is an attack surface.
Django Settings for Production
1# deploymart/settings.py 2 3import os 4from pathlib import Path 5from dotenv import load_dotenv 6 7load_dotenv() 8 9BASE_DIR = Path(__file__).resolve().parent.parent 10 11# SECURITY: Never run with DEBUG=True in production 12DEBUG = os.getenv('DJANGO_DEBUG', 'False').lower() == 'true' 13 14# SECURITY: Restrict to known domains 15ALLOWED_HOSTS = os.getenv( 16 'DJANGO_ALLOWED_HOSTS', 17 'localhost,127.0.0.1' 18).split(',') 19 20SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') 21if not SECRET_KEY: 22 raise ValueError("DJANGO_SECRET_KEY environment variable is required") 23 24# DATABASE: PostgreSQL via environment variables 25DATABASES = { 26 'default': { 27 'ENGINE': 'django.db.backends.postgresql', 28 'NAME': os.getenv('DB_NAME'), 29 'USER': os.getenv('DB_USER'), 30 'PASSWORD': os.getenv('DB_PASSWORD'), 31 'HOST': os.getenv('DB_HOST'), 32 'PORT': os.getenv('DB_PORT', '5432'), 33 'CONN_MAX_AGE': 600, # Connection pooling: reuse for 10 minutes 34 } 35} 36 37# CACHE: Redis for sessions and query caching 38CACHES = { 39 'default': { 40 'BACKEND': 'django_redis.cache.RedisCache', 41 'LOCATION': os.getenv('REDIS_URL', 'redis://redis:6379/0'), 42 'OPTIONS': { 43 'CLIENT_CLASS': 'django_redis.client.DefaultClient', 44 } 45 } 46} 47 48SESSION_ENGINE = 'django.contrib.sessions.backends.cache' 49SESSION_CACHE_ALIAS = 'default' 50 51# STATIC FILES: Whitenoise for serving behind Nginx 52# Nginx serves static files in production, but Whitenoise is the fallback 53STATIC_URL = '/static/' 54STATIC_ROOT = BASE_DIR / 'staticfiles' 55STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' 56 57# MEDIA FILES: User uploads 58MEDIA_URL = '/media/' 59MEDIA_ROOT = BASE_DIR / 'mediafiles' 60 61# SECURITY HEADERS 62SECURE_CONTENT_TYPE_NOSNIFF = True 63SECURE_BROWSER_XSS_FILTER = True 64X_FRAME_OPTIONS = 'DENY' 65 66# When behind a reverse proxy (Nginx), trust the X-Forwarded-Proto header 67SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 68USE_X_FORWARDED_HOST = True 69 70# LOGGING: Structured JSON logs for production observability 71LOGGING = { 72 'version': 1, 73 'disable_existing_loggers': False, 74 'formatters': { 75 'json': { 76 'format': '{"timestamp": "%(asctime)s", "level": "%(levelname)s", "message": "%(message)s", "module": "%(module)s"}', 77 }, 78 }, 79 'handlers': { 80 'console': { 81 'class': 'logging.StreamHandler', 82 'formatter': 'json', 83 }, 84 }, 85 'root': { 86 'handlers': ['console'], 87 'level': 'INFO', 88 }, 89} 90 91# REST Framework 92REST_FRAMEWORK = { 93 'DEFAULT_PERMISSION_CLASSES': [ 94 'rest_framework.permissions.IsAuthenticatedOrReadOnly', 95 ], 96 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 97 'PAGE_SIZE': 20, 98 'DEFAULT_THROTTLE_CLASSES': [ 99 'rest_framework.throttling.AnonRateThrottle', 100 'rest_framework.throttling.UserRateThrottle', 101 ], 102 'DEFAULT_THROTTLE_RATES': { 103 'anon': '100/hour', 104 'user': '1000/hour', 105 }, 106} 107 108# CORS 109CORS_ALLOWED_ORIGINS = [ 110 "https://app.deploymart.com", 111 "https://www.deploymart.com", 112]
Health Check Endpoint
Create a dedicated health check view that tests all critical dependencies:
1# deploymart/health.py 2from django.http import JsonResponse 3from django.db import connections 4from django.core.cache import cache 5from django.conf import settings 6import time 7 8def health_check(request): 9 """ 10 Comprehensive health check for Docker, Kubernetes, and load balancers. 11 Tests: database connectivity, cache connectivity, disk space. 12 """ 13 checks = { 14 'status': 'healthy', 15 'timestamp': time.time(), 16 'service': 'deploymart-api', 17 'version': '1.0.0', 18 } 19 status_code = 200 20 21 # Check database 22 try: 23 connections['default'].cursor().execute('SELECT 1') 24 checks['database'] = 'connected' 25 except Exception as e: 26 checks['database'] = f'error: {str(e)}' 27 checks['status'] = 'unhealthy' 28 status_code = 503 29 30 # Check cache 31 try: 32 cache.set('health_check', 'ok', timeout=5) 33 cache_value = cache.get('health_check') 34 checks['cache'] = 'connected' if cache_value == 'ok' else 'mismatch' 35 except Exception as e: 36 checks['cache'] = f'error: {str(e)}' 37 checks['status'] = 'unhealthy' 38 status_code = 503 39 40 # Check static files directory is writable (for collectstatic) 41 try: 42 import os 43 test_file = os.path.join(settings.STATIC_ROOT, '.health_check') 44 with open(test_file, 'w') as f: 45 f.write('ok') 46 os.remove(test_file) 47 checks['disk'] = 'writable' 48 except Exception as e: 49 checks['disk'] = f'error: {str(e)}' 50 51 return JsonResponse(checks, status=status_code) 52 53 54def readiness_check(request): 55 """ 56 Lighter check for Kubernetes readiness probes. 57 Only verifies the application is ready to receive traffic. 58 """ 59 return JsonResponse({ 60 'status': 'ready', 61 'service': 'deploymart-api', 62 })
1# deploymart/urls.py 2from django.urls import path 3from .health import health_check, readiness_check 4 5urlpatterns = [ 6 path('health/', health_check, name='health'), 7 path('ready/', readiness_check, name='ready'), 8 # ... other URLs 9]
Docker Compose Integration
1# docker-compose.yml (production snippet) 2services: 3 django: 4 build: 5 context: ./django 6 dockerfile: Dockerfile.prod 7 container_name: django_api 8 restart: unless-stopped 9 env_file: 10 - ./django/.env.prod 11 environment: 12 - DJANGO_SETTINGS_MODULE=deploymart.settings.prod 13 volumes: 14 - django_static:/app/staticfiles 15 - django_media:/app/mediafiles 16 networks: 17 - backend 18 - frontend 19 # Security hardening 20 user: "1000:1000" 21 read_only: true 22 tmpfs: 23 - /tmp:noexec,nosuid,size=100m 24 cap_drop: 25 - ALL 26 cap_add: 27 - CHOWN 28 - SETGID 29 - SETUID 30 depends_on: 31 postgres: 32 condition: service_healthy 33 redis: 34 condition: service_healthy 35 healthcheck: 36 test: ["CMD", "curl", "-f", "http://localhost:8000/health/"] 37 interval: 30s 38 timeout: 10s 39 retries: 3 40 start_period: 40s 41 deploy: 42 resources: 43 limits: 44 cpus: '1.0' 45 memory: 512M 46 reservations: 47 cpus: '0.25' 48 memory: 256M
Hands-On Lab: Build and Verify
Step 1: Create the Project Structure
1mkdir django-production-lab && cd django-production-lab 2mkdir -p deploymart deploymart/apps/products
Step 2: Build the Image
1docker build -f Dockerfile.prod -t django-prod:1.0 .
Step 3: Verify Image Size
1docker images django-prod 2# REPOSITORY TAG SIZE 3# django-prod 1.0 187MB
If your image is over 300MB, check for:
- Missing
.dockerignore(includes.git,node_modules) - Single-stage build (no
AS builder/AS production) - Dev dependencies in
requirements.txt
Step 4: Run and Test
1# Run with environment variables 2docker run -d --name django-test -p 8000:8000 -e DJANGO_SECRET_KEY=test-secret-key-for-lab-only -e DJANGO_DEBUG=False -e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 -e DB_NAME=deploymart -e DB_USER=deploymart -e DB_PASSWORD=testpass -e DB_HOST=host.docker.internal -e DB_PORT=5432 -e REDIS_URL=redis://host.docker.internal:6379/0 django-prod:1.0 3 4# Wait for health check 5docker inspect --format='{{.State.Health.Status}}' django-test 6# healthy 7 8# Test the API 9curl http://localhost:8000/health/ 10# {"status": "healthy", "database": "connected", "cache": "connected", ...} 11 12# Verify non-root user 13docker exec django-test whoami 14# django 15 16# Check processes 17docker exec django-test ps aux 18# PID 1: gunicorn master 19# PID 7: gunicorn worker 20# PID 8: gunicorn worker 21# PID 9: gunicorn worker 22# PID 10: gunicorn worker
Step 5: Load Test
1# Install Apache Bench 2sudo apt-get install apache2-utils 3 4# Send 1000 requests, 10 concurrent 5ab -n 1000 -c 10 http://localhost:8000/health/ 6 7# Expected: 0 failed requests, ~2000 req/sec on a modern laptop
Common Mistakes and Solutions
Mistake 1: Single-Stage Build
1# WRONG: Everything in one stage 2FROM python:3.12-slim 3RUN apt-get install -y build-essential libpq-dev gcc git 4COPY . . 5RUN pip install -r requirements.txt 6CMD python manage.py runserver 0.0.0.0:8000
Result: 1.2GB image with gcc, git, and build tools available to attackers.
Fix: Use multi-stage builds. Only the virtual environment and app code move to the production stage.
Mistake 2: Running as Root
1# WRONG: No USER directive 2FROM python:3.12-slim 3COPY . /app 4WORKDIR /app 5CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
Result: Container runs as root (UID 0). If an attacker escapes the container, they have root on the host.
Fix:
1RUN useradd -r -u 1000 django 2USER django
Mistake 3: Hardcoded Secrets
1# WRONG: Secrets in settings.py 2SECRET_KEY = 'django-insecure-hardcoded-secret' 3DATABASES = { 4 'default': { 5 'PASSWORD': 'super_secret_password', 6 } 7}
Result: Secrets committed to Git. Anyone with repo access sees production credentials.
Fix:
1SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') 2if not SECRET_KEY: 3 raise ValueError("DJANGO_SECRET_KEY is required")
Mistake 4: Missing Health Checks
1# WRONG: No HEALTHCHECK 2FROM python:3.12-slim 3COPY . /app 4CMD ["gunicorn", "app:app"]
Result: Docker thinks the container is "running" even if Django is stuck in a deadlock or cannot connect to the database.
Fix:
1HEALTHCHECK CMD curl -f http://localhost:8000/health/ || exit 1
Mistake 5: Using runserver in Production
1# WRONG: Dev server in production 2CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
Result: Single-threaded, no crash recovery, auto-reload wasting CPU, debug info exposed.
Fix:
1CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:8000", "deploymart.asgi:application"]
Mistake 6: Not Collecting Static Files
1# WRONG: No collectstatic 2COPY . /app 3CMD ["gunicorn", "app:app"]
Result: Django admin CSS and JS return 404. The admin panel is unusable.
Fix:
1RUN python manage.py collectstatic --noinput
Mistake 7: Forgetting CONN_MAX_AGE
1# WRONG: New connection per request 2DATABASES = { 3 'default': { 4 # ... no CONN_MAX_AGE 5 } 6}
Result: Every request opens and closes a PostgreSQL connection. Under load, PostgreSQL runs out of connections.
Fix:
1DATABASES = { 2 'default': { 3 # ... 4 'CONN_MAX_AGE': 600, # Reuse connections for 10 minutes 5 } 6}
Performance Benchmarks
| Metric | runserver | Gunicorn (4 workers) | Improvement |
|---|---|---|---|
| Requests/second | ~50 | ~2,000 | 40x |
| Concurrent users | 1 | 4+ | 4x+ |
| Image size | 1.2GB | 187MB | 6x smaller |
| Startup time | 15s | 3s | 5x faster |
| Memory per worker | N/A | ~50MB | Efficient |
| Crash recovery | Manual | Automatic | Self-healing |
Security Hardening Checklist
- Multi-stage build (builder + production stages)
- Non-root user (
USER django, UID ≥ 1000) - No secrets in image (use environment variables)
-
.dockerignoreexcludes.env,.git,node_modules - Health check endpoint (
/health/) with dependency checks - Read-only root filesystem (
read_only: true) - Dropped capabilities (
cap_drop: ALL) - tmpfs for
/tmp(noexec,nosuid) - Resource limits (CPU and memory)
- Pinned base image versions (no
latest) -
CONN_MAX_AGEfor database connection pooling - Whitenoise for static file serving fallback
- Security headers (
X-Frame-Options,X-Content-Type-Options) - Rate limiting on API endpoints
- Structured JSON logging to stdout
Mini Project: Production-Ready Django API
Build a Django REST API with these production requirements:
Specifications
-
Models:
Product(name, description, price, stock, created_at) -
Endpoints:
GET /api/products/— List products (paginated, cached)POST /api/products/— Create product (authenticated)GET /api/products/<id>/— Retrieve single productDELETE /api/products/<id>/— Delete product (authenticated)GET /health/— Full health check (DB + cache + disk)GET /ready/— Readiness check (lightweight)
-
Production Dockerfile Requirements:
- Multi-stage build under 200MB
- Non-root user (
django, UID 1000) - Gunicorn with 4 Uvicorn workers
- Health check configured
- Static files collected at build time
-
Security Requirements:
DEBUG=Falsein productionALLOWED_HOSTSrestrictedSECRET_KEYfrom environment- Rate limiting: 100/hour anonymous, 1000/hour authenticated
- CORS restricted to known origins
-
Verification:
1# Build image under 200MB 2docker build -f Dockerfile.prod -t django-prod:1.0 . 3docker images django-prod 4# SIZE < 200MB 5 6# Run and verify health 7docker run -d --name django-prod -p 8000:8000 -e DJANGO_SECRET_KEY=... django-prod:1.0 8 9curl http://localhost:8000/health/ 10# {"status": "healthy", "database": "connected", "cache": "connected"} 11 12# Verify non-root 13docker exec django-prod whoami 14# django 15 16# Load test 17ab -n 1000 -c 10 http://localhost:8000/health/ 18# 0 failed requests
What You Learned
| Concept | What It Is | Why It Matters |
|---|---|---|
| Multi-stage build | Separate compile and runtime stages | 6x smaller images, faster deploys |
| Gunicorn | Production WSGI/ASGI server | Concurrent requests, crash recovery |
| Uvicorn worker | ASGI worker class | Async view support, WebSocket ready |
| Non-root user | USER django in Dockerfile | Prevents container breakout |
| Health check | Docker HEALTHCHECK instruction | Auto-recovery, load balancer integration |
CONN_MAX_AGE | Database connection pooling | Prevents connection exhaustion |
| Whitenoise | Static file serving middleware | Fallback when Nginx is unavailable |
| Environment config | os.getenv() for all secrets | 12-factor app compliance |
| Structured logging | JSON format to stdout | Observability in production |
| Rate limiting | DRF throttle classes | Prevents API abuse |
Next Module
In Module 6: Next.js in Production, you will containerize a Next.js application with standalone output, reducing the image from 1.2GB to under 200MB. You will configure SSR, environment variables, and health checks for a production-ready frontend container.