Production Django deployments need at minimum four containers: web (Gunicorn), reverse proxy (Nginx), database (PostgreSQL), and worker (Celery). A fifth container for Redis is needed if you use Celery or Django's cache framework. This guide provides the complete configuration for all five.
Project Structure
myproject/
├── docker/
│ ├── nginx/
│ │ └── nginx.conf
│ └── django/
│ └── entrypoint.sh
├── myproject/
│ ├── settings/
│ │ ├── base.py
│ │ ├── development.py
│ │ └── production.py
│ └── ...
├── Dockerfile
├── docker-compose.yml
├── docker-compose.prod.yml
└── requirements.txt
Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
Key decisions:
- python:3.12-slim — smaller base image, production-appropriate
- PYTHONUNBUFFERED=1 — stdout/stderr are unbuffered so logs reach Docker immediately
- Non-root user — running as root in containers is a security risk
- libpq-dev — required by psycopg2 (PostgreSQL adapter)
docker-compose.prod.yml
version: "3.9"
services:
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data/
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
web:
build: .
command: >
sh -c "python manage.py migrate &&
python manage.py collectstatic --no-input &&
gunicorn myproject.wsgi:application
--bind 0.0.0.0:8000
--workers 3
--timeout 120
--access-logfile -
--error-logfile -"
volumes:
- static_volume:/app/staticfiles
- media_volume:/app/media
expose:
- 8000
env_file:
- .env.prod
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: always
worker:
build: .
command: celery -A myproject worker --loglevel=info --concurrency=2
env_file:
- .env.prod
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: always
beat:
build: .
command: celery -A myproject beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseScheduler
env_file:
- .env.prod
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: always
nginx:
image: nginx:alpine
volumes:
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf
- static_volume:/app/staticfiles
- media_volume:/app/media
ports:
- "80:80"
- "443:443"
depends_on:
- web
restart: always
volumes:
postgres_data:
static_volume:
media_volume:
Nginx Configuration
# docker/nginx/nginx.conf
upstream django {
server web:8000;
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
client_max_body_size 20M;
location /static/ {
alias /app/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /app/media/;
expires 7d;
}
location / {
proxy_pass http://django;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Nginx serves static and media files directly — without hitting Gunicorn. This is critical for performance; Gunicorn is not efficient at serving files.
Environment Variables (.env.prod)
# .env.prod — never commit this file
SECRET_KEY=your-production-secret-key-here
DEBUG=False
ALLOWED_HOSTS=example.com,www.example.com
POSTGRES_DB=mydb
POSTGRES_USER=myuser
POSTGRES_PASSWORD=strong-random-password
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=django-db
EMAIL_HOST=smtp.sendgrid.net
EMAIL_HOST_USER=apikey
EMAIL_HOST_PASSWORD=your-sendgrid-api-key
Use service names (redis, db) as hostnames within docker-compose — Docker's internal DNS resolves them.
Production Settings
# myproject/settings/production.py
from .base import *
DEBUG = False
SECRET_KEY = os.environ["SECRET_KEY"]
ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": "db",
"PORT": "5432",
"CONN_MAX_AGE": 60,
}
}
STATIC_ROOT = "/app/staticfiles"
MEDIA_ROOT = "/app/media"
SECURE_SSL_REDIRECT = False # Nginx handles this
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
Deployment Commands
# First deployment
docker-compose -f docker-compose.prod.yml up -d --build
# View logs
docker-compose -f docker-compose.prod.yml logs -f web
# Run Django management commands
docker-compose -f docker-compose.prod.yml exec web python manage.py createsuperuser
# Update and redeploy (zero-downtime is possible with multiple Gunicorn workers)
docker-compose -f docker-compose.prod.yml up -d --build web worker beat
Gunicorn Worker Count
The standard formula: (2 × CPU cores) + 1. For a 2-core VPS: 5 workers. For a 1-core VPS with limited RAM: 2–3 workers.
Each Gunicorn worker consumes approximately 50–100MB of RAM depending on what your application loads at startup. On a 2GB VPS with 5 workers: ~500MB for Django, leaving ~1.5GB for PostgreSQL, Redis, and Nginx.
Common Production Mistakes
Running migrate in every container startup: If you have multiple web containers, they all run migrate simultaneously — causing race conditions. Solution: run migrations in a separate init container or from the deploy script before scaling up.
Not setting depends_on with health checks: Without health checks, depends_on only waits for the container to start — not for the service inside to be ready. PostgreSQL is often not ready to accept connections when its container reports "started."
Storing secrets in docker-compose.yml: Use .env.prod files or Docker secrets — never put passwords directly in docker-compose.yml which may end up in version control.
If you need a production Django application containerized and deployed correctly — with Nginx, Gunicorn, PostgreSQL, Celery, and proper secret management — hire me as a Django developer or as an AWS developer.