Rohan Yeole - Homepage Rohan Yeole

Django Docker Deployment: Production Setup with Nginx, Gunicorn, and PostgreSQL

Apr 5, 20261 min read

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.

Chat with me on WhatsApp