Build Smaller, Faster Django Docker Images

Shrink and speed up Django Docker images: slim base images, multi-stage builds, layer ordering, BuildKit cache mounts, .dockerignore, and measurement.

Problem statement

A typical first Dockerfile for Django is built on a full Python image, copies the whole repository before installing dependencies, and installs compilers "just in case". The result is an image of well over a gigabyte that rebuilds every dependency whenever any file changes. That costs real time and money:

  • CI builds take minutes even for one-line changes
  • deploys pull large images to every server or node, slowing rollouts and autoscaling
  • registries store many large layers
  • the image contains compilers, caches, Git history, and possibly secrets that should never ship

Image size is not just cosmetic. Pull time directly affects how fast new instances start, and fewer packages mean fewer vulnerabilities to patch. This guide reduces size and build time step by step, measuring the effect of each change.

Shrinking the imageBaseslim runtimeBuild stagecompilers hereFinal stageapp and wheelsContextdockerignoreA multi-stage build keeps build tools out of the image you ship.

Quick answer

  1. Measure: docker images, docker history, and a tool such as dive to see which layers are large.
  2. Use a slim base image (python:3.13-slim), not the full image.
  3. Add a strict .dockerignore (Git history, virtualenvs, node_modules, media, local databases).
  4. Copy dependency files first and install dependencies in their own layer, so code changes do not reinstall packages.
  5. Use a multi-stage build: compilers and headers in the builder, only runtime libraries in the final image.
  6. Use BuildKit cache mounts for pip or uv caches instead of storing caches in layers.
  7. Clean package manager lists in the same RUN layer, run as a non-root user, and collect static files at build time.

Step-by-step solution

1. Measure the starting point

docker build -t myapp:before .
docker images myapp
docker history --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" myapp:before | head -20

docker history lists layers with their sizes and the instruction that created them. For an interactive view of which files each layer adds, dive myapp:before is very helpful.

Also time a rebuild after changing one Python file:

touch config/urls.py
time docker build -t myapp:before .

2. Choose the base image

BaseTypical use
python:3.13full Debian with build tools; large
python:3.13-slimminimal Debian; good default for Django
python:3.13-alpinevery small, but musl libc means many binary wheels do not apply, slower builds, and subtle compatibility differences
the base image sets the floorApprox. sizeTrade-offpython:3.12~1 GBbuild tools includedpython:3.12-slim✓~130 MB✓good defaultpython:3.12-alpine~50 MB!musl: slow or failing wheelsdistrolesssmallestno shell to debug withSizes are approximate and vary by versiondjango-deployment.com
Slim plus a multi-stage build is the usual sweet spot.

For Django, slim is usually the best trade-off. Pin a specific variant such as python:3.13-slim-bookworm (or a digest) for reproducible builds.

3. Add a .dockerignore

Everything in the build context is sent to the builder, and a broad COPY . . puts it into the image.

.git
.gitignore
.venv
venv
__pycache__/
*.pyc
node_modules
media/
staticfiles/
*.sqlite3
.env
.env.*
docker-compose*.yml
compose*.yaml
tests/fixtures/large/
coverage/
.pytest_cache/

This speeds up context transfer, avoids cache invalidation from irrelevant files, and keeps secrets such as .env out of images.

4. Order layers for caching

Docker reuses a layer if its instruction and inputs are unchanged, and invalidates every layer after the first change. Put things that change rarely first:

FROM python:3.13-slim

WORKDIR /app

# Changes rarely: dependency list
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Changes often: application code
COPY . .

With this order, editing a view only rebuilds the final COPY layer.

5. Multi-stage build

Packages such as psycopg (source build), lxml, or Pillow may need compilers when wheels are unavailable. Keep build tools out of the final image:

# syntax=docker/dockerfile:1
FROM python:3.13-slim-bookworm AS builder

ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PYTHONDONTWRITEBYTECODE=1

RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential libpq-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

FROM python:3.13-slim-bookworm AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"

RUN apt-get update && apt-get install -y --no-install-recommends \
        libpq5 \
    && rm -rf /var/lib/apt/lists/*

RUN useradd --uid 10001 --create-home app
WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
COPY --chown=app:app . .

RUN DJANGO_SECRET_KEY=build-only python manage.py collectstatic --noinput \
    && chown -R app:app /app/staticfiles

USER app
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
what each step savedpython:3.12, one stage~1.25 GBslim base~520 MBslim + multi-stage~310 MB+ .dockerignore, no caches~240 MB0 MB650 MB1300 MBMBIllustrative numbers; measure with docker image lsdjango-deployment.com
The base image and build tools dominate; code is usually small.

What this achieves:

  • the builder has compilers and headers; the runtime has only libpq5
  • the virtualenv is created at /opt/venv in the builder and copied to the same path, so its scripts keep working
  • the pip cache lives in a BuildKit cache mount, reused across builds but never stored in an image layer
  • rm -rf /var/lib/apt/lists/* happens in the same RUN as apt-get install; deleting files in a later layer does not reduce image size

If all your dependencies have binary wheels (for example psycopg[binary]), you can skip compilers entirely and use a single stage.

6. Don't leave caches or build artifacts in layers

Common hidden size:

  • pip cache: use --no-cache-dir or a cache mount
  • apt lists: remove in the same layer
  • .pyc files from build steps: PYTHONDONTWRITEBYTECODE=1 during build, or accept them if you precompile deliberately for faster startup
  • test data, docs, and frontend sources: exclude or build them in a separate stage and copy only outputs
clean up in the same layerRUN apt-get update && apt-get install -y--no-install-recommendslibpq5 \&&rm -rf /var/lib/apt/lists/*RUN pip install--no-cache-dir-r requirements.txt1231Skip optional packages.2Same RUN: a later RUN cannot shrink this layer.3No pip download cache in the layer.Deleting files in a later layer hides them but keeps their sizedjango-deployment.com
BuildKit cache mounts speed builds without adding to the image.

7. Frontend assets

If your project builds JavaScript and CSS with Node, do it in a separate stage and copy only the built files:

FROM node:22-slim AS assets
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

# in the runtime stage, before collectstatic:
# COPY --from=assets /frontend/dist /app/static/dist

Node and node_modules never reach the final image.

8. Measure the result

DOCKER_BUILDKIT=1 docker build -t myapp:after .
docker images myapp
touch config/urls.py && time docker build -t myapp:after .

Compare:

  • total image size (before versus after)
  • rebuild time after a code-only change
  • pull time on a fresh machine: docker image rm myapp:after && time docker pull registry.example.com/myapp:after

It is common to go from over 1 GB to roughly 200 to 300 MB for a typical Django app with the slim base and multi-stage build, and from minutes to seconds for code-only rebuilds. Your numbers depend on dependencies; measure rather than assume.

9. Scan and keep it small

Smaller images contain fewer packages with known vulnerabilities, but not zero. Scan images in CI (for example with Trivy or your registry's scanner), rebuild regularly to pick up base image security updates, and review new system packages added to the Dockerfile.

Verification check:

  • the final image has no compilers (docker run --rm myapp:after which gcc prints nothing)
  • .env, .git, and local databases are not in the image
  • the container runs as a non-root user
  • code-only changes rebuild in seconds using cached dependency layers
  • the application passes smoke tests from the new image

Explanation

Docker images are stacks of immutable layers. Size comes from what each layer adds, including files later deleted in other layers, and build speed comes from how many layers can be reused. Ordering instructions from least to most frequently changing maximizes reuse; multi-stage builds separate what is needed to build from what is needed to run; .dockerignore and same-layer cleanup keep accidental content out. Together they produce images that are faster to build, pull, and patch.

Edge cases / notes

Distroless and minimal images

Distroless or scratch-based Python images are even smaller but lack a shell, which makes debugging harder and entrypoint scripts impossible. They suit mature teams with good observability.

Compile Python bytecode at build time

Precompiling (python -m compileall) increases image size slightly but can reduce cold start time, which matters for autoscaling and serverless-style platforms.

Multiple architectures

Building for linux/amd64 and linux/arm64 with docker buildx produces separate images per platform; make sure wheels exist for both or the builder compiles packages.

Caching builds in CI

Local builds reuse layers automatically; CI runners often start empty, rebuilding everything each time. Export and import the build cache:

# GitHub Actions
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/myorg/myapp:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

The gha cache backend stores layers in the GitHub Actions cache. Registry-based caches (type=registry) work with other CI systems. mode=max caches intermediate stages too, which matters for multi-stage builds where the builder stage contains the expensive dependency installation.

Reading dive output

dive shows two useful numbers for an image:

  • wasted space: bytes added in one layer and removed or overwritten in a later one. A high value points to cleanup done in separate RUN instructions.
  • efficiency score: the share of image content that is not wasted.

In the layer view, select the largest layers and inspect the file tree for unexpected directories, such as /root/.cache/pip, /var/lib/apt/lists, test fixtures, or copied .git directories. Each finding maps to a concrete fix in the Dockerfile or .dockerignore.

A size budget in CI

Prevent regressions by failing the build when the image grows beyond an agreed size. The check below needs the image in the local Docker engine, so build with load: true (or pull the pushed image) before running it:

size=$(docker image inspect --format '{{.Size}}' ghcr.io/myorg/myapp:"$GITHUB_SHA")
limit=$((400 * 1024 * 1024))
if [ "$size" -gt "$limit" ]; then
  echo "Image is $((size / 1024 / 1024)) MB, above the 400 MB budget"
  exit 1
fi

Adjust the budget when a legitimate dependency requires it, and review the change like any other performance regression.

Common mistakes

  • COPY . . before installing dependencies.
  • Running apt-get clean or deleting caches in a separate RUN instruction.
  • Shipping .git, .env, and media/ because there is no .dockerignore.
  • Switching to Alpine to save space and then spending build time compiling wheels.

Next steps

FAQ

What base image should I use for Django in Docker?

python:3.x-slim (Debian-based) is a good default: small, compatible with binary wheels, and easy to extend. Pin a specific version tag for reproducible builds.

Why is my Django Docker image so large?

Usually a full Python base image, compilers left in the final image, caches and apt lists stored in layers, and a build context that includes .git, virtualenvs, or node_modules.

Should I use Alpine for Django images?

Usually not. Alpine uses musl libc, so many Python binary wheels do not apply, builds compile packages from source, and the size savings are small compared to slim images with multi-stage builds.

How do I make Docker rebuilds faster for Django?

Copy dependency files and install packages before copying application code, use BuildKit cache mounts for package caches, and keep the build context small with .dockerignore.

Learning path · step 7 of 12

Docker, from image to production

Keep going