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.
- Build dependencies in a
python:3.12-slim(or matching Debian) stage into a virtualenv. - Use a distroless Python runtime (
gcr.io/distroless/python3-debian12) or Chainguard's Python image as the final stage. - Make sure the builder's Python version matches the runtime's, or compiled wheels will not load.
- Copy the virtualenv and app, run
collectstaticin the builder, and use exec-formENTRYPOINT/CMD(no shell). - 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.
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.
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
HEALTHCHECKwithcurldoes not work without curl; use orchestrator HTTP probes.
Next steps
- Reduce size further with Optimize Django Docker Image Size.
- Run non-root correctly with Run Django Docker Containers as Non-Root.
- Verify the result with Scan Django Docker Images With Trivy.
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.