Deployment
#django
#docker
#postgresql
#nginx

Deploy Django with Docker Compose in Production

Many Docker Compose examples for Django are built for local development, not production.

Problem statement

Many Docker Compose examples for Django are built for local development, not production. They expose the app server directly, run with DEBUG=True, commit secrets to version control, skip TLS, and provide no safe process for migrations or rollback.

A production-safe Django Docker Compose setup needs a few clear boundaries:

  • Django runs behind Gunicorn
  • only the reverse proxy exposes ports 80/443
  • PostgreSQL and Redis stay on the internal Docker network
  • secrets are injected at runtime, not baked into the image
  • migrations and static files are handled deliberately
  • the previous image is kept for rollback

If you want to deploy Django with Docker Compose on a single VPS or small Linux server, Compose can work well, but only if you use it as a real release system rather than a development shortcut.

Quick answer

A safe Django production Docker Compose setup on one host usually looks like this:

  • web: Django app running with Gunicorn
  • nginx or caddy: reverse proxy and TLS termination
  • db: PostgreSQL with a named volume
  • redis: optional, if your app actually uses cache, Celery, or channels
  • .env: runtime secrets and environment variables stored on the server
  • named volumes for database, media, and static persistence where needed

A practical release flow is:

  1. build or pull a tagged image
  2. upload or update the production .env
  3. start the database if needed
  4. run database migrations as an explicit one-off command
  5. run collectstatic
  6. start or update services with docker compose up -d
  7. verify containers, logs, HTTPS, and static files
  8. keep the previous image tag for rollback

Step-by-step solution

1. Define the production architecture

Minimum Compose stack for Django in production

Use these services:

  • web: Django + Gunicorn
  • nginx: reverse proxy
  • db: PostgreSQL
  • redis: only if your project needs it
  • named volumes for postgres_data, media_data, and static_data

Public exposure rules:

  • expose only nginx on 80:80 and 443:443
  • do not publish PostgreSQL, Redis, or Gunicorn ports
  • let Docker networking handle service-to-service traffic

This is the main difference between a development Compose file and a production one: internal services stay private.

2. Prepare Django settings for production

In your production settings, read values from environment variables and enable HTTPS-aware settings:

import os

DEBUG = os.environ.get("DJANGO_DEBUG", "False").lower() == "true"

ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if o]

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True

STATIC_URL = "/static/"
STATIC_ROOT = "/app/staticfiles"

MEDIA_URL = "/media/"
MEDIA_ROOT = "/app/media"

If you want HSTS, enable it only after HTTPS is working correctly:

SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = False

Store secrets in a server-side .env file, not in Git:

DJANGO_SECRET_KEY=replace-me
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com

POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=strong-password
POSTGRES_HOST=db
POSTGRES_PORT=5432

If your app uses Redis, add it too:

REDIS_URL=redis://redis:6379/0

Copy the file to the server and lock permissions:

mkdir -p /srv/myapp
cp .env /srv/myapp/.env
chmod 600 /srv/myapp/.env

Verification:

ls -l /srv/myapp/.env

Rollback note: enabling SECURE_SSL_REDIRECT before proxy headers are correct can cause redirect loops or login issues. Verify proxy behavior first.

3. Write the production Dockerfile

Use a slim base image and keep runtime behavior predictable:

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential libpq-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser

CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60", "--access-logfile", "-", "--error-logfile", "-"]

This gives you a clean baseline:

  • Gunicorn is the app server
  • logs go to stdout/stderr for docker compose logs
  • the process runs as a non-root user
  • secrets are not embedded in the image

Build locally to verify:

docker build -t myapp:latest .

For repeatable releases, prefer immutable tags such as myapp:2026-04-24 rather than relying only on latest.

4. Create a production compose file

For production, it is clearer to reference a tagged image explicitly. If you build on the server, do it before deployment and update the image tag intentionally.

services:
  web:
    image: myapp:2026-04-24
    env_file:
      - /srv/myapp/.env
    depends_on:
      - db
    restart: unless-stopped
    volumes:
      - media_data:/app/media
      - static_data:/app/staticfiles
    expose:
      - "8000"

  db:
    image: postgres:16
    env_file:
      - /srv/myapp/.env
    restart: unless-stopped
    volumes:
      - postgres_data:/var/lib/postgresql/data

  nginx:
    image: nginx:1.27-alpine
    depends_on:
      - web
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - static_data:/var/www/static:ro
      - media_data:/var/www/media:ro
      - ./certs:/etc/nginx/certs:ro

volumes:
  postgres_data:
  media_data:
  static_data:

If your project uses Redis, add it:

  redis:
    image: redis:7
    restart: unless-stopped

And then add redis back to web.depends_on and your application settings.

Notes:

  • depends_on does not guarantee that PostgreSQL is ready to accept connections
  • do not mount your full source tree into the production container
  • pin major service versions where practical

Backup note: a persistent volume is not a backup strategy.

5. Configure Gunicorn and Nginx

Nginx config

Create nginx/default.conf:

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    client_max_body_size 10m;

    location /static/ {
        alias /var/www/static/;
    }

    location /media/ {
        alias /var/www/media/;
    }

    location / {
        proxy_pass http://web:8000;
        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;
        proxy_read_timeout 60;
    }
}

This is the standard reverse proxy pattern for Django with Docker Compose: Nginx terminates TLS, forwards protocol headers, and serves static and media files.

TLS note

The example above assumes certificate files already exist in ./certs. That is acceptable, but production also requires a certificate renewal plan. If you do not want to manage certificate files manually, use an ACME-capable proxy workflow such as Caddy or a Certbot-based Nginx setup.

6. Handle migrations and static files safely

Do not run migrations automatically every time the container starts. That makes ordinary restarts risky.

Run migrations explicitly:

docker compose run --rm web python manage.py migrate

Collect static files explicitly too:

docker compose run --rm web python manage.py collectstatic --noinput

If you want Nginx to serve static files from a volume, mount the same named volume at Django’s STATIC_ROOT in web and at the proxy static path in nginx. Then collectstatic writes into the shared volume that Nginx serves.

If docker compose run --rm web python manage.py migrate fails because PostgreSQL is not ready yet, start db first and wait briefly before retrying:

docker compose up -d db

Verification:

docker compose logs web --tail=50
docker compose logs nginx --tail=50

Rollback note: take a database backup before destructive or high-risk migrations.

7. Deploy the stack on the server

On Ubuntu, install Docker and open only the needed ports:

sudo apt-get update
sudo apt-get install -y docker.io docker-compose-plugin
sudo systemctl enable --now docker
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Create the deployment directory:

sudo mkdir -p /srv/myapp
sudo chown $USER:$USER /srv/myapp
cd /srv/myapp

If you build on the server, tag the release image:

docker build -t myapp:2026-04-24 .

Then run the release steps:

docker compose up -d db
docker compose run --rm web python manage.py migrate
docker compose run --rm web python manage.py collectstatic --noinput
docker compose up -d --no-build

Check status:

docker compose ps

Check open ports:

ss -tulpn | grep -E ':80|:443|:5432|:6379|:8000'

You should only see public bindings for 80 and 443.

8. Verify the deployment

Application checks:

docker compose ps
docker compose logs web --tail=100
docker compose logs nginx --tail=100
curl -I https://example.com

Verify:

  • homepage returns 200 or the expected redirect
  • admin login works
  • static assets load
  • forms do not fail CSRF checks
  • DEBUG=False

Security checks:

  • PostgreSQL is not publicly reachable
  • Redis is not publicly reachable
  • HTTPS redirect works
  • forwarded headers are present
  • only 80/443 are exposed externally

9. Rollback and recovery

Tag images by release, not only latest:

docker build -t myapp:2026-04-24 .
docker build -t myapp:2026-04-10 .

In production, point the web service at a specific image tag such as:

web:
  image: myapp:2026-04-24

If a release fails, change the web image back to the previous tag and recreate the app without rebuilding:

docker compose up -d --no-build web nginx
docker compose ps

Before risky migrations, create a database backup:

docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backup.sql

Important: rolling back the application image is only safe when schema changes remain backward compatible. If a migration removes columns, changes constraints, or reshapes data, rollback may require restoring the database, not just switching to an older image.

Explanation

This setup works because it separates concerns clearly:

  • Django serves the application
  • Gunicorn handles Python web requests
  • Nginx handles public traffic, TLS, and static/media delivery
  • PostgreSQL stays private on the Docker network
  • Redis can stay private too, if used
  • Compose gives you repeatable service definitions on a single host

For production Docker Compose with Django, this is a good fit when you have:

  • one VPS or one Linux server
  • low to moderate traffic
  • a small team
  • no immediate need for rolling deploys or multi-host orchestration

Choose another approach when you need multi-host failover, native rolling deployments, or more advanced scheduling.

When to turn this into a script or template

Once your release steps are stable, the repetitive parts are good candidates for automation: server bootstrap, .env validation, image tagging, migrations, health checks, backups, and rollback selection. A reusable template also helps keep proxy headers, volumes, and service definitions consistent across projects.

Edge cases / notes

  • Database readiness: depends_on is not enough. The database container may be running before PostgreSQL is actually ready.
  • CSRF and HTTPS issues: if SECURE_PROXY_SSL_HEADER or X-Forwarded-Proto is missing, Django may treat requests as HTTP and reject secure forms.
  • Static files: pick one clear strategy. In this guide, Nginx serves static files from a shared volume populated by collectstatic.
  • Media files: named volumes work on a single host. Object storage is usually a better fit if you may move to multiple servers.
  • TLS lifecycle: if you mount certificate files into Nginx, you also need a certificate issuance and renewal process.
  • Compose limits: Docker Compose is useful for one-host deployments, but it does not provide cluster orchestration or native rolling updates.
  • Gunicorn tuning: set worker counts and timeouts based on your app and server size, not copied defaults.

Before deploying with containers, review the broader Django Deployment Checklist for Production.

If you want the non-container equivalent of this architecture, see Deploy Django with Gunicorn and Nginx on Ubuntu.

If you prefer a proxy that handles certificate automation for you, see Deploy Django with Caddy and Automatic HTTPS.

If you need an ASGI stack for websockets or async views, see Deploy Django ASGI with Uvicorn and Nginx.

FAQ

Is Docker Compose suitable for Django in production?

Yes, for a single-host deployment. It works well for small teams, client projects, internal tools, and moderate traffic applications. It is less suitable when you need multi-host orchestration or rolling deploys across several servers.

Should I run migrations automatically when the container starts?

Usually no. Automatic startup migrations turn a simple restart into a schema change event. It is safer to run migrations as an explicit release step so you can back up first and verify the result.

Do I need Nginx or Caddy in front of Django when using Docker Compose?

In most production setups, yes. A reverse proxy handles TLS termination, secure headers, buffering, and static/media delivery more cleanly than exposing Gunicorn directly.

How should I handle static and media files in a Docker Compose deployment?

Static files should usually be collected during the release and served by the reverse proxy. Media files need persistent storage, such as a named volume on a single host or object storage if you may scale beyond one server.

What is the safest rollback approach for a failed Django Compose deployment?

Keep the previous image tag, switch the web service back to it, and recreate the affected services with --no-build. For schema-changing releases, back up the database first because application rollback does not undo database changes.

2026 · django-deployment.com - Django Deployment knowledge base