Filter concepts by levelShowing all levels.

Django · Section 93

Docker for Django

Level
intermediate
Read
22 min
Concepts
2

This section is deliberately narrow: the roadmap defers Dockerfile syntax, layer-caching mechanics, volumes, networking and multi-stage builds to the Docker roadmap, and keeps only what is specific to putting Django in a container. That turns out to be two questions. First, what the image should contain. Start from an official `python:3.12-slim` base, pinned to a minor version so a rebuild of the same commit produces the same image; `alpine` is tempting for its size and wrong for this stack, because musl means many Python packages have no prebuilt wheel and get compiled from source, which costs build minutes and drags a toolchain into the image. Then order the layers by how often they change, because a layer is reused only when everything above it is unchanged: copy `pyproject.toml` and the lock file, install from them, and copy the source last. Get that backwards and every commit reinstalls every dependency, which is the most common reason a Django image takes minutes to build. `pyproject.toml` is where the dependency list and `requires-python` — "the minimum version of Python that you support" — are declared, and a lock file is what makes the image you ship the one that was tested. Two things must stay out of the build: secrets, because an `ENV` value becomes a permanent, distributable layer that survives every rotation, and migrations, because a build must be a pure function of the repository and must not touch a database. `collectstatic` is the case that does belong there — no database, and identical output in every environment. Finish with a non-root `USER`, so an application bug is bounded by an unprivileged account inside the container. Second, what the stack looks like. Four containers: the application, a worker, PostgreSQL and Redis. The worker is the *same image* with a different command, and that sameness is the whole point — Celery tasks import the same models, settings and service functions, so a separately built worker image is a version skew waiting to appear in the least-tested paths. PostgreSQL needs a named volume, or a teardown takes the data with it. Redis is two different promises depending on the job: a cache that may be lost, and a broker holding work that has not run yet. Start-up order is the part that catches people, because plain `depends_on` waits for a container to be *started* rather than ready, so the app connects to a PostgreSQL that is still initialising and exits — reliably on a cold machine, never on the one where the volume already exists. The fix is a health check with `condition: service_healthy`, and for migrations a one-shot container the app waits on with `service_completed_successfully`, so the schema is applied exactly once instead of once per replica. Configuration arrives through `env_file` at run time, which is what lets one built image be promoted from staging to production unchanged.

What is true here

  1. Generic Docker belongs to the Docker roadmap; this is the Django-shaped remainder.
  2. slim over alpine, pinned to a minor version, wheels installed as wheels.
  3. Manifest → install → source: layer order is the entire build-time story.
  4. No secrets and no migrations in a build; collectstatic is fine there.
  5. One image, two commands — and wait for healthy, never for started.

What you will be able to do

  • Write a Dockerfile where an ordinary code change rebuilds in seconds
  • Say what must never be baked into an image, and why an ENV secret is permanent
  • Bring up app, worker, PostgreSQL and Redis with correct readiness ordering
  • Run migrations exactly once during a rollout rather than once per replica
From a commit to a running stack

pyproject.toml + lock

dependencies and requires-python, declared once

layers, least-changing first

manifest → install → source

one image, tagged

static collected, no secrets, non-root USER

migrate — one-shot

exits zero before anything else starts

app + worker

same image, two commands

postgres + redis

healthchecked; only one of them keeps state

  1. pyproject.toml + lock — dependencies and requires-python, declared once
  2. layers, least-changing first — manifest → install → source
  3. one image, tagged — static collected, no secrets, non-root USER
  4. migrate — one-shot — exits zero before anything else starts
  5. app + worker — same image, two commands
  6. postgres + redis — healthchecked; only one of them keeps state

The image

Which base, which layer order, and what must never be baked in.

The Python image, and installing dependencies in the right order

coreintermediate

A Django image starts from an official **Python base image**, installs your dependencies, then copies your source. That order is deliberate: dependencies change rarely and source changes constantly, so putting the install first means an ordinary code change reuses the cached install instead of repeating it. **`pyproject.toml`** is where the dependency list and the supported Python version are declared — the Packaging Guide calls it "a configuration file used by packaging tools, as well as other tools such as linters, type checkers".

Think of it as

Two decisions make or break a Django image, and both are about matching a choice to a constraint rather than following a recipe. The first is the base. The `slim` variants of the official Python images are the sensible default for Django: they carry a working interpreter and enough of a userland that the wheels you need install cleanly, without the build toolchain a full image drags along. The `alpine` variants look attractive because they are smaller, and they are a trap for this stack specifically — Alpine uses musl rather than glibc, so a great many Python packages have no prebuilt wheel for it and are compiled from source at build time, which turns a thirty-second install into several minutes and pulls in a compiler you then have to remove. Pin the base to a specific minor version, `python:3.12-slim`, because `3` and `latest` are moving targets and a base that silently moves under you is a build that reproduces differently on different days. The second decision is layer order, and it is entirely about what changes. Every instruction produces a layer, and a layer is reused only when everything before it is unchanged. Dependencies change when you deliberately add one; source changes on every commit. So copy the dependency manifest alone, install from it, and only then copy the source: an ordinary code change invalidates the last layers and reuses the install. Copy the source first and every commit reinstalls every package, which is the single most common reason a Django image takes four minutes to build. The manifest itself is where `pyproject.toml` earns its place. It holds the project metadata in a standard `[project]` table — `name`, `version`, `dependencies`, and `requires-python`, which the Packaging Guide defines as "the minimum version of Python that you support" — so the interpreter your image must supply is written down beside the packages, and `[build-system]`, which "should always be present". For reproducibility you want the resolved versions too: a lock file gives you the exact set that was tested, so the image built today matches the one built during the release. Finally, know what does not belong in the image. Secrets must not: `ENV SECRET_KEY=...` writes the value into an immutable, distributable layer that survives every rotation. Migrations must not be run at build time either, because a build has no business touching a database and the same image is meant to run against staging and production. `collectstatic` is the interesting middle case — it needs no database and produces files identical for every environment, so running it during the build is right, and it is one less thing to do while a container is starting.

dockerfile
COPY pyproject.toml uv.lock ./     # dependencies change rarely
RUN pip install --no-cache-dir -e .
COPY . .                           # source changes constantly

What we're doing: Build a Django image that rebuilds in seconds after a code change, runs as a non-root user, and contains no secrets.

Dockerfiledockerfile
# Pinned to a minor version. "python:3" would move under you between
# two builds of the same commit. slim, not alpine: glibc means the
# wheels for psycopg and friends install as wheels, not as source.
FROM python:3.12-slim

# Unbuffered output so log lines reach the journal as they happen
# rather than when a buffer fills, and no .pyc files to write.
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    DJANGO_SETTINGS_MODULE=config.settings.production
# Nothing secret here. An ENV value is a permanent, distributable layer.

WORKDIR /srv/app

# Runtime libraries only. The build toolchain is not installed, because
# every dependency below ships a manylinux wheel for this base.
RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 \
 && rm -rf /var/lib/apt/lists/*

# THE ORDER THAT MATTERS. Manifest and lock file only, so this layer and
# the install below survive every commit that does not add a dependency.
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir -e .

# Source last: the only layer an ordinary code change invalidates.
COPY . .

# No database is involved, and the output is identical in every
# environment, so this belongs in the build rather than in start-up.
RUN python manage.py collectstatic --noinput

# Run as a non-root user. Everything an application bug can reach is
# bounded by this account.
RUN useradd --system --no-create-home appuser \
 && chown -R appuser:appuser /srv/app
USER appuser

CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
1–4
Two choices in one line: pinned minor version for reproducibility, and `slim` so packages install from prebuilt wheels. `alpine` would compile several of them from source and need a toolchain in the image.
6–11
`PYTHONUNBUFFERED` matters in a container because stdout is a pipe rather than a terminal, so without it Python buffers and your logs arrive late or, after a crash, not at all.
15–19
Runtime library, not the `-dev` package: `libpq5` is what psycopg needs to *run*. Cleaning the apt lists in the same `RUN` keeps them out of the layer, since a later `rm` would not shrink it.
21–24
The cache boundary. Copying only the manifest and lock file means the install layer is reused for every commit that does not change dependencies — which is nearly all of them.
29–31
`collectstatic` needs no database and produces the same output everywhere, so it is deterministic build work. `migrate` is not, and belongs in a release step against a real database.
33–37
`USER` after everything is installed: the build needs to write, the running process does not. A container running as root gives a compromised dependency root inside the container.

Why this works: A one-line code change rebuilds only the final layers, the image carries no build toolchain and no secret, static files are already collected, and the process runs unprivileged.

Copying the source before installing dependencies

Wrong

dockerfile
COPY . .
RUN pip install --no-cache-dir -e .
# every commit invalidates COPY, so every commit reinstalls everything

Better

dockerfile
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir -e .
COPY . .

What you see: CI takes three or four minutes per build no matter how small the change, and the log shows the same packages being downloaded and installed on every run.

Why: A layer is reused only when every layer above it is byte-identical, so a `COPY . .` that changes on every commit invalidates everything below it. Putting the install below that copy means the install can never be cached. Splitting the copy in two puts the volatile part last: the manifest and lock file change only when you deliberately add a dependency, so the install layer survives ordinary commits. The rule generalises beyond Python — order Dockerfile instructions from least to most frequently changing — but this is the instance of it that costs Django teams the most time.

Two Dockerfiles, one code change — what the cache does with each

The only difference is where the source is copied. Putting it before the install makes every commit reinstall every dependency, because a layer is reused only when everything above it is unchanged.

  • Two stacks of layers side by side, each representing a Dockerfile, after a one-line change to a Python view.
  • The left stack, labelled "source copied first", has four layers: the base image and the system packages are green and marked cached, then COPY . . is red and marked changed, and the dependency install below it is red and marked rebuilt. A note says the whole install repeats on every commit.
  • The right stack, labelled "manifest copied first", has five layers: base image, system packages, COPY pyproject.toml, and the dependency install are all green and marked cached, and only the final COPY . . layer is red and marked changed.
  • A summary line contrasts the two: roughly three minutes of reinstall on the left against a few seconds on the right, for exactly the same code change.

Choosing the base image for a Django app

Choosing the base image for a Django app
BaseSizeUse it when
`python:3.12-slim`moderate**the default** — glibc, so wheels install as wheels
`python:3.12`largeyou genuinely need the full build toolchain at run time
`python:3.12-alpine`smallestrarely — musl means source builds for many packages
a distro base + your own Pythonvariesa platform team already standardises on one

Together

dockerfile
FROM python:3.12-slim
# Matches requires-python = ">=3.12" in pyproject.toml — one supported
# interpreter version, written down in both places.

What belongs at build time, and what does not

What belongs at build time, and what does not
StepBuild time?Why
install dependenciesyesthe layer you most want cached
`collectstatic`yesno database needed, and identical in every environment
compile translations (`compilemessages`)yessame reason — deterministic, no services involved
`migrate`**no**a build must not touch a database; one image, many environments
secrets (`ENV SECRET_KEY=…`)**no**an image layer is permanent and distributable
`DJANGO_SETTINGS_MODULE`yesnot a secret — it names which settings file to load

Together

dockerfile
ENV DJANGO_SETTINGS_MODULE=config.settings.production
RUN python manage.py collectstatic --noinput
# migrate runs as a release step against a real database, not here

Remember: Start from `python:3.12-slim`, pinned to a minor version — `alpine` is musl and turns wheel installs into source builds. Copy the manifest and lock file, install, and only then copy the source, because a layer is reused only when everything above it is unchanged and source changes on every commit. Declare dependencies and `requires-python` in `pyproject.toml` and lock the resolved versions, so the image built today is the one that was tested. Run `collectstatic` at build time; keep migrations and secrets out of the build entirely, because a build must not touch a database and an `ENV` secret is a permanent layer. Finish with a non-root `USER`.

See also: app worker database and cache containers · users groups permissions and environment · static settings finders and collectstatic · the expand and contract technique

Advertisement

The stack

Four containers, one image, and readiness ordering that is not guesswork.

Four containers: app, worker, PostgreSQL, Redis

coreintermediate

A Django stack usually runs as four containers. The **application container** serves requests. The **worker container** runs background tasks — the same image, a different command. **PostgreSQL** holds the data that must survive, so it needs a volume. **Redis** holds the broker queue and the cache, which are usually allowed to be lost. Compose starts them in an order you declare, and can wait for a dependency to be *healthy* rather than merely started.

Think of it as

The useful way to see the split is by what each container is allowed to lose and what starts it. The application container is stateless: it holds no data of its own, so it can be killed, replaced or scaled at will, and that property is what makes rolling deploys and autoscaling possible. The worker is the same image with a different command — that sameness is the point, because the tasks import your models, your settings and your service functions, so a worker built from a separate image is a second thing to keep in step and a new class of "works in the web tier, fails in the worker" bug. They differ in what they need at run time: the worker usually needs no port and no static files, and often gets a smaller memory budget and its own scaling rule, because a queue backlog and a traffic spike are different signals. PostgreSQL is the container that must not lose anything, so its data directory is a named volume; without one, `docker compose down` deletes the database, which is a lesson people only learn once. Redis is the interesting middle: as a Celery broker it holds messages that have not been processed yet, and as a cache it holds values that can be recomputed. Losing the cache costs a slow minute; losing the broker queue loses queued work, so if that matters you either persist it or accept it deliberately rather than by accident. Start-up order is the other half. Compose's plain `depends_on` waits for a container to be *started*, which for PostgreSQL means the process exists and is very possibly still initialising, so the application starts, tries to connect, and crashes. The fix that Compose documents is a health check plus `condition: service_healthy`, which "specifies that a dependency is expected to be 'healthy' (as indicated by healthcheck) before starting". Migrations get their own treatment: a one-shot service that runs `migrate` and exits, with the application waiting on `condition: service_completed_successfully` — so the schema is applied exactly once, by one container, rather than by every replica racing. In production that same one-shot becomes a release step in your deploy pipeline. Two habits keep this honest. Configuration comes in through `env_file` at run time, so the image stays environment-agnostic and contains no secret. And the development conveniences — a bind mount over the source, `runserver`, `DEBUG=1` — belong in an override file, not in the shape you ship.

yaml
depends_on:
  postgres: { condition: service_healthy }

What we're doing: Bring up the whole stack in one command, with the app waiting for a database that is genuinely ready and a migration that has genuinely finished.

compose.yamlyaml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: storefront
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
    volumes:
      # Named volume. Without this line, "compose down -v" — or a
      # careless "compose down" on some setups — deletes the database.
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d storefront"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 20s

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

  # One-shot. It runs migrate, exits, and nothing else starts until it
  # has exited ZERO — so the schema is applied once, not once per replica.
  migrate:
    image: storefront:1.14.0
    command: python manage.py migrate --noinput
    env_file: [.env]
    depends_on:
      postgres: { condition: service_healthy }

  app:
    image: storefront:1.14.0
    command: gunicorn config.wsgi:application --bind 0.0.0.0:8000
    env_file: [.env]          # secrets arrive at run time, never in the image
    ports: ["8000:8000"]
    depends_on:
      postgres: { condition: service_healthy }
      redis:    { condition: service_healthy }
      migrate:  { condition: service_completed_successfully }

  # Same image, different command. The tasks import the same models and
  # settings, so a separate worker image would be a second thing to keep
  # in step — and a new way for the two to disagree.
  worker:
    image: storefront:1.14.0
    command: celery -A config worker --concurrency 4
    env_file: [.env]
    depends_on:
      redis:   { condition: service_healthy }
      migrate: { condition: service_completed_successfully }

volumes:
  pgdata:
8–11
The named volume is the difference between a database and a scratch pad. Anonymous volumes are removed with the container, and this is the mistake that is only made once.
12–17
`pg_isready` is the healthcheck that means something: the server is accepting connections for that database. `start_period` covers first-run initialisation, when failures should not count against `retries`.
26–33
Migrations as a one-shot service. Compose documents `service_completed_successfully` as "a dependency is expected to run to successful completion", which is exactly the guarantee you want before any application container starts.
38–43
`env_file` at run time is what keeps the image environment-agnostic. The same `storefront:1.14.0` runs in staging and production, differing only in what it is handed.
45–53
The worker names the same image tag and a different command. Anything else — a second Dockerfile, a second build — creates a version skew that shows up as tasks failing on models the web tier already has.

Why this works: Nothing starts before its dependencies are actually ready, the schema is applied exactly once, the database survives a teardown, and the worker cannot drift away from the code the web tier is running.

Relying on `depends_on` without a healthcheck

Wrong

yaml
app:
  depends_on: [postgres]     # waits for STARTED, not for ready
  command: gunicorn config.wsgi

Better

yaml
app:
  depends_on:
    postgres: { condition: service_healthy }

What you see: `compose up` fails on a cold machine and works on the second attempt. The app logs `could not connect to server` once, restarts, and everything is fine — so it is written off as flaky rather than fixed.

Why: Plain `depends_on` orders container *creation*. PostgreSQL's container is started long before PostgreSQL is accepting connections — on a first run it is still initialising the data directory — so the application connects to nothing and exits. The behaviour is timing-dependent, which is why it fails in CI and on new laptops and never on the machine where the volume already exists. Compose's answer is a healthcheck plus `condition: service_healthy`, which waits for the check to pass rather than for the process to exist. A retry loop in your entrypoint is the same idea implemented worse: it hides a real ordering requirement inside application code.

One image, two roles, two backing services

Two roles from that image

app

gunicorn · a port · static collected

worker

celery · no port · scales on queue depth

beat (optional)

the schedule — exactly one replica, ever

migrate (one-shot)

runs, exits zero, then the app may start

Backing services — different data, different promises

postgres

named volume — losing this loses everything

redis (broker)

holds work not yet run

redis (cache)

recomputable — safe to lose

  • storefront:1.14.0 — one built image
  • Two roles from that image — built once, promoted through environments, started two different ways
    • app — gunicorn · a port · static collected
    • worker — celery · no port · scales on queue depth
    • beat (optional) — the schedule — exactly one replica, ever
    • migrate (one-shot) — runs, exits zero, then the app may start
  • Backing services — different data, different promises — each with a healthcheck, because "started" is not "ready"
    • postgres — named volume — losing this loses everything
    • redis (broker) — holds work not yet run
    • redis (cache) — recomputable — safe to lose

The four containers, and what each may lose

The four containers, and what each may lose
ContainerCommandStateScale on
app`gunicorn config.wsgi`none — fully replaceablerequest rate and latency
worker`celery -A config worker`none — the queue holds the statequeue depth and task age
postgresthe official image**must survive** — named volumenot horizontally; this one you size
redisthe official imagecache: losable · broker: queued workmemory, mostly

Together

yaml
app:
  image: storefront:1.14.0
  command: gunicorn config.wsgi:application --bind 0.0.0.0:8000
worker:
  image: storefront:1.14.0        # the SAME image
  command: celery -A config worker --concurrency 4

`depends_on` conditions, and what each actually waits for

`depends_on` conditions, and what each actually waits for
ConditionWaits untilUse it for
`service_started` (the default)the container has been startedalmost nothing — the process may not be ready
`service_healthy`its healthcheck passesPostgreSQL and Redis, always
`service_completed_successfully`it ran and exited zerothe one-shot migration container

Together

yaml
depends_on:
  postgres: { condition: service_healthy }
  migrate:  { condition: service_completed_successfully }

Remember: Four containers, one image: the app and the worker are the same build with different commands, which is what stops them drifting apart. PostgreSQL needs a named volume or a teardown takes the data with it; Redis as a cache is losable, Redis as a broker holds work that has not run yet. `depends_on` on its own waits for *started*, so add a healthcheck and `condition: service_healthy`, and run migrations as a one-shot service the app waits on with `service_completed_successfully` — one execution, not one per replica. Secrets arrive through `env_file` at run time so the image stays environment-agnostic.

See also: the python image and dependency install · celery app tasks and workers · redis roles in a django stack · degrading falling back and dead lettering

Advertisement