Build a Distroless Docker Image for Django

Build a minimal, shell-less production image for Django using a multi-stage build with a Debian builder and a distroless or Chainguard Python runtime: copy the virtualenv, run as non-root, handle collectstatic and migrations without a shell, and debug safely.

Problem statement

Vulnerability scanners flag dozens of CVEs in your Django image, most in packages the app never uses: shells, package managers, and system utilities from the base image. A distroless runtime image contains only Python, its libraries, and your app, with no shell or package manager, which shrinks the attack surface and the scan report. The cost is that common habits, such as sh -c entrypoints and apt-get in the final image, no longer work.

Distroless Django imageBuilder stageDebian, pip, gccCopy venvsite-packagesRuntime stagePython only, no shellNon-root userexec-form CMDBuild with a full image, run on one that contains only what the app needs.
  1. Build dependencies in a python:3.12-slim (or matching Debian) stage into a virtualenv.
  2. Use a distroless Python runtime (gcr.io/distroless/python3-debian12) or Chainguard's Python image as the final stage.
  3. Make sure the builder's Python version matches the runtime's, or compiled wheels will not load.
  4. Copy the virtualenv and app, run collectstatic in the builder, and use exec-form ENTRYPOINT/CMD (no shell).
  5. Run as the non-root user the image provides, and use ephemeral debug containers instead of a shell.

Step-by-step solution

1. Match Python versions

Distroless Python images ship the distribution's Python (for Debian 12, Python 3.11). Your builder must produce a virtualenv for the same version and architecture. Either build on debian:12 with its python3, or use Chainguard images that publish several Python versions with matching -dev builder variants.

2. Write the multi-stage Dockerfile

# builder: same Debian release and Python as the runtime
FROM debian:12-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv python3-dev gcc libpq-dev \
 && rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /venv
COPY requirements.txt .
RUN /venv/bin/pip install --no-cache-dir -r requirements.txt
WORKDIR /app
COPY . .
RUN DJANGO_SETTINGS_MODULE=config.settings.build /venv/bin/python manage.py collectstatic --noinput

# runtime: no shell, no package manager
FROM gcr.io/distroless/python3-debian12:nonroot
COPY --from=build /venv /venv
COPY --from=build /app /app
WORKDIR /app
ENV PYTHONUNBUFFERED=1 PATH="/venv/bin:$PATH"
EXPOSE 8000
ENTRYPOINT ["/venv/bin/python", "-m", "gunicorn"]
CMD ["config.wsgi", "--bind", "0.0.0.0:8000", "--workers", "3"]

The virtualenv's python symlink points to /usr/bin/python3, which exists at the same path in the distroless image. Use psycopg[binary] to avoid needing libpq at runtime, or copy the shared library into the runtime stage.

OS packages in the runtime image (illustrative)python:3.12 (full)110python:3.12-slim45distroless python3no shell060120Fewer packages means fewer CVEs to triagedjango-deployment.com
Counts vary by release; scan your own images to compare.

3. Run commands without a shell

There is no sh, so entrypoint scripts and sh -c "migrate && gunicorn" fail with exec: "sh": executable file not found. Use exec form and run separate commands as separate containers or jobs:

docker run --rm myapp:3f2a1c9 manage.py migrate --noinput    # wrong: ENTRYPOINT is gunicorn
docker run --rm --entrypoint /venv/bin/python myapp:3f2a1c9 manage.py migrate --noinput

In Kubernetes, set command: ["/venv/bin/python", "manage.py", "migrate", "--noinput"] on the migration Job. Any startup logic that lived in a shell script moves into Python (a management command) or into the orchestrator.

4. Run as non-root

The :nonroot tag runs as UID 65532. Make sure the app does not need to write inside /app; logs go to stdout, media goes to object storage, and any temporary files go to /tmp (writable). With a read-only root filesystem in Kubernetes, mount an emptyDir at /tmp.

5. Debug without a shell

Use the :debug variant temporarily (it includes BusyBox), or attach an ephemeral debug container in Kubernetes:

kubectl debug -it pod/web-abc123 --image=busybox:1.36 --target=web

This gives you a shell in the pod's namespaces without shipping one in the production image.

symptom and fixSymptomCause and fixexec: sh not foundshell-form CMD; use exec-form arraysImportError on .so filesPython or libc mismatch; match builderlibpq.so.5 missinguse psycopg[binary] or copy the libPermissionError writingnonroot user; write to /tmp or storageMost issues come from assumptions about a shell or full OSdjango-deployment.com
Run the test suite against the final image in CI to catch these early.

Explanation

Distroless images are built from Debian packages but include only the runtime files a language needs: the interpreter, the C library, CA certificates, and timezone data. Without a shell, package manager, or core utilities, there is far less for an attacker to use after a compromise and far fewer packages for scanners to report. Multi-stage builds make this practical: everything needed to compile and collect lives in the builder, and only results are copied forward.

Edge cases / notes

  • Chainguard images. Chainguard publishes minimal Python images with frequent rebuilds and several Python versions; free tags are limited, check their terms.
  • System libraries. Packages needing GDAL, WeasyPrint's Pango, or similar must have those libraries copied into the runtime stage, which can erode the benefit.
  • Health checks. Docker HEALTHCHECK with curl does not work without curl; use orchestrator HTTP probes.

Next steps

FAQ

Can I run Django migrations in a distroless container?

Yes, by overriding the entrypoint to /venv/bin/python manage.py migrate in a separate container or Job.

Why do compiled packages fail to import?

The builder used a different Python version or C library than the runtime. Build on the same Debian release and Python.

How do I debug a distroless container?

Use the :debug tag temporarily or attach an ephemeral debug container with kubectl debug.

Is distroless worth it for small projects?

It is optional. Slim images with regular updates and scanning are adequate for many teams.

Keep going