The Python image, and installing dependencies in the right order
coreintermediateA 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.
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.
- 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
Better
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 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
Together
What belongs at build time, and what does not
Together
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

