Speed Up Django Docker Builds With Layer and Cache Mounts
Make Django Docker builds fast in CI and locally: order layers so dependencies cache, BuildKit cache mounts for pip and uv, registry-backed cache for CI, .dockerignore, and multi-stage builds that compile once.
Problem statement
Every push rebuilds the Django image from scratch: pip install downloads and compiles the same packages for five minutes, even when only a template changed. In CI, each runner starts with an empty Docker cache, so nothing is reused between builds. Slow builds lengthen feedback loops and deploys and cost CI minutes. Docker's layer cache and BuildKit features (cache mounts, registry-backed cache) can bring typical rebuilds down to well under a minute.
- Copy the lock file and install dependencies before copying the rest of the code.
- Use BuildKit cache mounts for package downloads:
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt(or/root/.cache/uvfor uv). - In CI, persist the build cache with
cache-from/cache-to(registry or GitHub Actions cache backend). - Add a
.dockerignoreexcluding.git,node_modules, media, virtualenvs, and local databases. - Use multi-stage builds: compile wheels or frontend assets once in builder stages, copy results.
- Pin base images so cache keys stay stable between builds.
Step-by-step solution
1. Order layers for the cache
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
COPY . .
RUN SECRET_KEY=build python manage.py collectstatic --noinput
Docker reuses a layer when its instruction and inputs are unchanged. With the lock file copied alone first, the expensive pip install layer is rebuilt only when dependencies change. Copying all code first invalidates it on every commit.
2. Cache mounts for downloads
--mount=type=cache keeps pip's (or uv's) download cache between builds on the same builder, even when the layer itself must rebuild because one dependency changed. With uv:
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /bin/uv
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-install-project
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
3. Persist the cache in CI
Fresh runners have no local cache. Export and import it:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/you/myapp:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/you/myapp:buildcache
cache-to: type=registry,ref=ghcr.io/you/myapp:buildcache,mode=max
mode=max caches intermediate stages too (builder stages). The GitHub Actions cache backend (type=gha) is an alternative with size limits. Note that cache mounts are not exported; layer caching does most of the work in CI.
4. Keep the build context small
# .dockerignore
.git
.venv
node_modules
media/
staticfiles/
*.sqlite3
__pycache__/
.env
A large context slows every build before the first instruction runs, and stray files (like .env or local media) can end up in images and break caching.
5. Multi-stage builds
Build frontend assets and Python wheels in separate stages and copy only results into the runtime image:
FROM node:22-slim AS assets
WORKDIR /src
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY frontend/ frontend/
RUN npm run build
FROM base AS runtime
COPY --from=assets /src/dist /app/static/dist
Each stage caches independently, so a Python change does not rebuild assets and vice versa.
Verification check: rebuild after editing a template and confirm the dependency steps show CACHED in the BuildKit output; compare CI build duration before and after enabling registry cache.
Explanation
Docker images are stacks of layers, each identified by its instruction and inputs. When an early layer changes, every later layer must rebuild. Placing slow, rarely changing steps (installing dependencies) before fast, frequently changing ones (copying code) maximises reuse. BuildKit adds cache mounts, which persist tool caches across builds without baking them into the image, and exportable caches, which let ephemeral CI runners start from a previous build's layers. Together they make rebuild time proportional to what actually changed.
Edge cases / notes
- Cache invalidation.
ARGvalues and environment variables used in aRUNaffect cache keys; set build args like commit SHA late in the Dockerfile. - Security updates. Heavy caching can keep stale OS packages; schedule rebuilds with
--pull --no-cacheperiodically. - Multi-arch builds cache per platform; cross-compiling under QEMU is slow regardless, so prefer native runners.
- Remote builders (Depot, BuildKit on a dedicated host) keep caches warm across all CI jobs.
Next steps
- Make images smaller too with Optimize Django Docker Image Size.
- Wire it into deployment with Deploy Django With GitHub Actions CI/CD.
FAQ
Why does my Django Docker build reinstall packages every time?
Code is copied before installing dependencies, or the CI runner has no cache. Copy the lock file first and persist the cache.
What are BuildKit cache mounts?
Persistent caches for tools like pip and npm that survive between builds without being stored in image layers.
How do I cache Docker layers in GitHub Actions?
Use buildx with cache-from and cache-to pointing at a registry or the GitHub Actions cache.
Does caching risk stale security updates?
It can. Rebuild without cache on a schedule so base image and package updates are picked up.