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 versionshould printv2.x). - A working Django 5.x project with a
requirements.txt(or apyproject.tomlif you use Poetry/uv). gunicorn,psycopg[binary](PostgreSQL driver),whitenoiseanddj-database-urladded 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.0Project 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 appsThe 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-slimbase — small, current, security-patched. Avoidlatestand ancientpython:3.xtags.- A dedicated non-root user (
appuser) — never run the app as root. PYTHONDONTWRITEBYTECODEandPYTHONUNBUFFEREDfor clean container logging.collectstaticbaked into the image so static assets ship with the artifact.- A gunicorn entrypoint. Use
ENV KEY=value(the legacyENV KEY valueform is deprecated) andLABELfor metadata (the oldMAINTAINERdirective 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_Store12-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=djangodocker 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 createsuperuserBuild 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 downServing 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
-slimbases, use multi-stage builds, and combineapt-getinstall + cleanup in oneRUNso the cache layer isn't kept. - Layer caching: copy
requirements.txtand 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:latestor Trivy) in CI. Never bakeSECRET_KEYorDATABASE_URLinto 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_URLat 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.