How to Dockerize a Django Project

Blog / Server Management · December 17, 2024 · Updated June 10, 2026 · 10 min read
How to Dockerize a Django Project

Dockerizing a Django project means packaging your application, its Python dependencies and its runtime configuration into a single immutable image that runs identically on a laptop, a CI runner and a production server. The result is a reproducible deployment: no more "works on my machine", predictable rollbacks, and an artifact you can ship to AWS ECS, Kubernetes, Fly.io or a plain Docker host without surprises.

This guide builds a production-grade container for Django 5.x on Python 3.12 using a multi-stage Dockerfile, gunicorn as the WSGI server, a non-root user, WhiteNoise for static files, and Docker Compose v2 to wire Django to PostgreSQL and Redis. Every snippet is current for Docker in 2026 — note the space-separated docker compose (the legacy hyphenated docker-compose v1 is end-of-life).

Prerequisites

Before you start, make sure you have:

  • Docker Engine 24+ with the Compose v2 plugin (docker compose version should print v2.x).
  • A working Django 5.x project with a requirements.txt (or a pyproject.toml if you use Poetry/uv).
  • gunicorn, psycopg[binary] (PostgreSQL driver), whitenoise and dj-database-url added to your requirements.
  • Familiarity with 12-factor config: every environment-specific value comes from an environment variable, never from code committed to git.

A minimal requirements.txt looks like this:

Django>=5.0,<5.2
gunicorn>=22.0
psycopg[binary]>=3.2
whitenoise>=6.7
dj-database-url>=2.2
redis>=5.0

Project layout

Keep the Docker files at the repository root, next to manage.py. A typical layout:

myproject/
├── Dockerfile
├── .dockerignore
├── compose.yaml
├── requirements.txt
├── manage.py
├── myproject/            # settings package
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   ├── wsgi.py
│   └── asgi.py
└── apps/                 # your Django apps

The production Dockerfile (multi-stage)

A multi-stage build compiles wheels in a fat "builder" stage, then copies only the installed packages into a lean final image. This keeps the runtime image small and free of build toolchains (gcc, headers) that you don't want shipping to production.

Key decisions in the Dockerfile below:

  • python:3.12-slim base — small, current, security-patched. Avoid latest and ancient python:3.x tags.
  • A dedicated non-root user (appuser) — never run the app as root.
  • PYTHONDONTWRITEBYTECODE and PYTHONUNBUFFERED for clean container logging.
  • collectstatic baked into the image so static assets ship with the artifact.
  • A gunicorn entrypoint. Use ENV KEY=value (the legacy ENV KEY value form is deprecated) and LABEL for metadata (the old MAINTAINER directive is removed).
# syntax=docker/dockerfile:1

###########
# Builder #
###########
FROM python:3.12-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# Build deps needed to compile any wheels (removed from the final image).
RUN apt-get update && apt-get install --no-install-recommends -y \
        build-essential libpq-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Install into an isolated prefix we can copy wholesale into the runtime stage.
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt

###########
# Runtime #
###########
FROM python:3.12-slim AS runtime

LABEL org.opencontainers.image.title="django-app" \
      org.opencontainers.image.source="https://github.com/your-org/myproject"

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    DJANGO_SETTINGS_MODULE=myproject.settings \
    PORT=8000

# Runtime-only OS deps (libpq for psycopg, curl for the healthcheck).
RUN apt-get update && apt-get install --no-install-recommends -y \
        libpq5 curl \
    && rm -rf /var/lib/apt/lists/* \
    && addgroup --system app && adduser --system --ingroup app appuser

# Copy the prebuilt Python packages from the builder stage.
COPY --from=builder /install /usr/local

WORKDIR /app
COPY . .

# Collect static files at build time so the artifact is self-contained.
RUN SECRET_KEY=build-only-dummy DATABASE_URL=sqlite:// \
    python manage.py collectstatic --noinput

# Drop privileges.
RUN chown -R appuser:app /app
USER appuser

EXPOSE 8000

# gunicorn: workers ~ (2 * CPU) + 1; tune per host.
CMD ["gunicorn", "myproject.wsgi:application", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "3", \
     "--timeout", "60", \
     "--access-logfile", "-", \
     "--error-logfile", "-"]

Why multi-stage?

A single-stage image carries the entire build toolchain into production. Multi-stage discards it. The difference is large and improves both pull times and attack surface.

Aspect Single-stage Multi-stage
Typical image size ~1.0–1.3 GB ~180–250 MB
Build tools in runtime Yes (gcc, headers) No
Attack surface Larger Smaller
Layer caching of deps OK Better isolated
Pull / cold-start time Slower Faster

Keeping the runtime image lean is one of the simplest, highest-leverage wins when you move a Django service to production.

The .dockerignore

Without a .dockerignore, the entire build context — including .git, virtualenvs, local databases and secrets — is sent to the Docker daemon and can leak into your image. Always add one:

.git
.gitignore
.dockerignore
Dockerfile
compose.yaml

# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
env/

# Local artifacts & secrets
.env
.env.*
*.sqlite3
db.sqlite3
staticfiles/
media/

# Tooling
.pytest_cache/
.mypy_cache/
.coverage
htmlcov/
.DS_Store

12-factor settings: read config from the environment

Never bake secrets into the image. Read everything sensitive from environment variables at runtime. Using dj-database-url collapses host, port, user, password and database name into one DATABASE_URL string. Add this to settings.py:

import os
import dj_database_url

SECRET_KEY = os.environ["SECRET_KEY"]
DEBUG = os.environ.get("DEBUG", "0") == "1"
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")

DATABASES = {
    "default": dj_database_url.config(
        default=os.environ.get("DATABASE_URL", "sqlite:///db.sqlite3"),
        conn_max_age=600,
        conn_health_checks=True,
    )
}

# WhiteNoise serves compressed, hashed static files straight from gunicorn.
STATIC_URL = "/static/"
STATIC_ROOT = "/app/staticfiles"
STORAGES = {
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",  # right after SecurityMiddleware
    # ... the rest of your middleware
]

Define the actual values in a .env file that is git-ignored and dockerignored. Compose loads it automatically. Treat this file as a secret; in real production use a secrets manager (AWS Secrets Manager, SSM Parameter Store, Vault) rather than a plain file.

# .env  (never commit this)
SECRET_KEY=replace-with-a-50-char-random-string
DEBUG=0
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
DATABASE_URL=postgres://django:django@db:5432/django
REDIS_URL=redis://redis:6379/0
POSTGRES_USER=django
POSTGRES_PASSWORD=django
POSTGRES_DB=django

docker compose: Django + PostgreSQL + Redis

For local development and small single-host deployments, Compose v2 wires the services together on a private network. The db and redis services get healthchecks, and the web service waits for the database to be healthy via depends_on: condition: service_healthy. The modern file is named compose.yaml and needs no version: key.

# compose.yaml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  web:
    build: .
    env_file: .env
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/healthz/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s

volumes:
  pgdata:

Running migrations

Never run migrate inside the image build — the database isn't available then, and baking it in breaks immutability. Run migrations as a one-off command against the running stack, or as a release step in your deploy pipeline:

# Apply migrations against the running services
docker compose run --rm web python manage.py migrate

# Create a superuser interactively
docker compose run --rm web python manage.py createsuperuser

Build and run

Build the image and bring the stack up. With BuildKit (default in modern Docker), unchanged layers — including your pip install layer — are cached, so rebuilds after a code-only change are fast because requirements.txt is copied before the rest of the source.

# Build the image
docker build -t myproject:latest .

# Start the full stack in the background
docker compose up -d --build

# Tail the web logs
docker compose logs -f web

# Apply migrations
docker compose run --rm web python manage.py migrate

# Open http://localhost:8000  — then tear down
docker compose down

Serving static files: WhiteNoise vs nginx

There are two common patterns for static assets. WhiteNoise is the simplest and ships in the snippets above; nginx is worth it once you front multiple services or want it terminating TLS and serving media.

Concern WhiteNoise (in gunicorn) nginx sidecar/reverse proxy
Setup effort Minimal (one middleware) Extra container + config
Best for Small/medium apps, PaaS High traffic, TLS, large media
TLS termination Use a proxy/load balancer Built in
Compression & caching Yes (manifest storage) Yes (fine-grained)
Media (user uploads) Use S3/object storage nginx or S3

For most containerized Django apps, start with WhiteNoise and add nginx only when traffic or media handling demands it. If you do add nginx, our walkthrough on setting up nginx as a reverse proxy covers the proxy config.

gunicorn vs uvicorn: WSGI or ASGI?

The CMD above runs gunicorn against wsgi:application — the right default for traditional, synchronous Django. If your app uses async views, Django Channels, WebSockets or Server-Sent Events, run an ASGI server instead.

Factor gunicorn (WSGI) uvicorn / gunicorn+uvicorn workers (ASGI)
Protocol WSGI (sync) ASGI (async)
WebSockets / SSE No Yes
Best for Standard Django views Async views, Channels, real-time
Entry point myproject.wsgi:application myproject.asgi:application
Maturity for sync Django Battle-tested default Use when you need async

For ASGI, swap the entrypoint to gunicorn driving uvicorn workers:

CMD ["gunicorn", "myproject.asgi:application", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "3", \
     "--timeout", "60"]

Healthchecks

The Compose web healthcheck above hits /healthz/. Add a tiny, dependency-light view so orchestrators (Compose, ECS, Kubernetes) know the container is alive without touching the database on every probe:

# urls.py
from django.http import JsonResponse
from django.urls import path

def healthz(_request):
    return JsonResponse({"status": "ok"})

urlpatterns = [
    path("healthz/", healthz),
    # ... your other routes
]

Production notes & checklist

  • Image size: prefer -slim bases, use multi-stage builds, and combine apt-get install + cleanup in one RUN so the cache layer isn't kept.
  • Layer caching: copy requirements.txt and install before copying source, so dependency layers are reused across code changes.
  • Security: run as a non-root user, pin dependency versions, and scan images (docker scout cves myproject:latest or Trivy) in CI. Never bake SECRET_KEY or DATABASE_URL into the image.
  • Logging: log to stdout/stderr (gunicorn --access-logfile -) and let the platform collect logs.
  • Config per environment: one image, many environments, all driven by env vars — the 12-factor way.
  • Database: in real production, point DATABASE_URL at a managed database (Amazon RDS, Cloud SQL) rather than a Postgres container.
  • Static & media: WhiteNoise for static; object storage (S3/GCS) for user uploads.

At MicroPyramid we have spent 12+ years shipping Django and DevOps work across 50+ projects, and a clean container pipeline like this is what makes our deployments repeatable. For lifting an existing Django app into containers and onto AWS or GCP, see our cloud migration services; for ongoing patching, monitoring and incident response on the running stack, see our server maintenance services. Two related reads: serving Django with gunicorn and configuring PostgreSQL with Django.

Frequently Asked Questions

Why use a multi-stage Dockerfile for Django?

A multi-stage build compiles Python wheels and any C extensions in a temporary builder stage, then copies only the installed packages into a slim runtime image. This drops a typical Django image from over 1 GB to roughly 200 MB, removes the compiler toolchain from production, shrinks the attack surface, and speeds up image pulls and container cold starts.

Should I run migrations inside the Dockerfile?

No. The database is not available during the image build, and baking migrations in breaks the immutability of the artifact. Run docker compose run --rm web python manage.py migrate against the running stack, or add migrations as a dedicated release/deploy step in your CI/CD pipeline so they execute once per deployment.

How do I serve static files from a Dockerized Django app?

The simplest approach is WhiteNoise: add its middleware right after Django's SecurityMiddleware, use CompressedManifestStaticFilesStorage, and run collectstatic during the image build so assets ship inside the artifact. For high-traffic sites or large media, front the app with nginx for static delivery and TLS, and store user uploads in object storage such as Amazon S3.

Gunicorn or uvicorn for Django in Docker?

Use gunicorn against wsgi:application for standard synchronous Django — it is the battle-tested default. Switch to an ASGI server (uvicorn, or gunicorn with uvicorn workers against asgi:application) when you need async views, Django Channels, WebSockets or Server-Sent Events. Most CRUD-style Django apps are well served by gunicorn.

How should I manage secrets and environment variables?

Follow 12-factor config: never commit SECRET_KEY, database credentials or API keys to git or bake them into the image. Read them from environment variables at runtime via an env_file in Compose for local work, and a managed secrets store — AWS Secrets Manager, SSM Parameter Store or HashiCorp Vault — in production. Keep .env in both .gitignore and .dockerignore.

What is the difference between docker-compose and docker compose?

docker-compose (hyphen) is the legacy standalone Python v1 tool, which is end-of-life. docker compose (space) is the current Compose v2 plugin built into Docker. Use the v2 syntax: name the file compose.yaml, omit the obsolete top-level version: key, and run docker compose up, docker compose run and docker compose down.

Share this article