Build lean, secure, production-ready Django Docker images. Multi-stage builds, dependency caching, non-root users, compiled static files, and health checks that shrink images from 1.2GB to 150MB.
A naive Django Dockerfile produces an image that is bloated, slow to build, and full of things an attacker would love — compilers, build tools, source artifacts, and far more attack surface than a running app needs. Multi-stage builds fix all of this: you build in one stage with all the tools, then copy only the finished result into a lean final image. This tutorial covers production-ready Django images: multi-stage structure, layer caching, security hardening, and the practices that keep images small, fast, and safe.
Your container image is the artifact that runs in production, and its quality directly affects security, cost, and deployment speed. A bloated image is slow to build, slow to push and pull, expensive to store, and — most importantly — carries a larger attack surface, because every tool and library in it is something that could harbor a vulnerability. A lean, well-built image deploys faster, costs less, and gives an attacker far less to work with. Treating the Dockerfile as throwaway boilerplate is a mistake; it is production infrastructure that deserves the same care as your code. The techniques here turn a careless 1GB image into a tight, secure one a fraction of the size.
A single-stage Dockerfile installs everything in one image: the Python build toolchain, compilers needed for packages with C extensions, development headers, your source, and the runtime. The result ships all of that to production, even though the build tools are useless once the app is built and the compilers are pure liability. The image is large, and it contains a wide attack surface of software the running app never uses. This is the default people reach for, and it is exactly what multi-stage builds were created to fix — separating what you need to build the app from what you need to run it.
A multi-stage build defines multiple FROM stages in one Dockerfile. An early "builder" stage has all the tools and compiles or installs your dependencies; a later "final" stage starts fresh from a clean base and copies only the finished artifacts from the builder. The build tools never make it into the final image.
FROM python:3.12-slim AS builder
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.12-slim
COPY --from=builder /root/.local /root/.local
COPY . /app
The final image contains your app and its runtime dependencies, but none of the compilers or build machinery — smaller, faster, and dramatically reduced attack surface.
The short example above shows the shape of the idea; a real Django image needs a few more pieces. The version below builds dependencies into a virtual environment in the builder, installs only the runtime shared library in the final stage, collects static files, and drops privileges before starting gunicorn:
# syntax=docker/dockerfile:1
ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-slim 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/*
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:${PYTHON_VERSION}-slim AS final
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH" \
DJANGO_SETTINGS_MODULE=myproject.settings
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --no-create-home --shell /usr/sbin/nologin appuser
WORKDIR /srv/app
COPY --from=builder /opt/venv /opt/venv
COPY . .
RUN DJANGO_SECRET_KEY=collectstatic-only python manage.py collectstatic --noinput
USER appuser
EXPOSE 8000
CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
A few decisions in this file are worth spelling out, because each one avoids a real failure:
pip install --user. Installing into /root/.local works while the container runs as root, but once you switch to a non-root user that user usually cannot read /root at all, and the app fails with ModuleNotFoundError. A venv at a neutral path like /opt/venv sidesteps that. Because a venv hard-codes its own absolute path in its scripts, it must live at the same path in both stages, and both stages must use the same Python base image.libpq-dev and build-essential are needed to compile psycopg from source; the running app only needs the shared library libpq5. If you use the psycopg[binary] wheel, which bundles its own libpq, you can drop both — but the pattern applies to any package with C extensions that does not ship a wheel for your platform.appuser. The code and collected static files are written by root during the build, so the unprivileged runtime user can read them but cannot modify them. An attacker who gets code execution cannot persist by rewriting a Python module in the image.The base image is the foundation, and the choice trades size against convenience. The full Python image is large but has everything; the -slim variant is much smaller and suits most Django apps; Alpine-based images are tiny but use musl libc, which can cause subtle issues with some Python packages that expect glibc and may even build slower due to compiling wheels from source. For most Django deployments, python:3.12-slim is the sweet spot — small, compatible, and predictable. Pick a specific version tag rather than latest so your builds are reproducible. The base choice sets the floor for your image size and a good deal of its security posture.
Docker builds in layers, caching each one and rebuilding only from the first changed layer onward — which means the order of your Dockerfile instructions hugely affects build speed. The key technique is to copy and install dependencies before copying your source code, so that changing your code does not invalidate the expensive dependency-installation layer:
COPY requirements.txt .
RUN pip install -r requirements.txt # cached unless requirements change
COPY . /app # changes often, but cheap
Because your code changes far more often than your dependencies, this ordering means most builds skip the slow install step entirely. Getting layer order right is the single biggest lever on build speed.
By default, the Docker build sends your entire directory to the daemon as build context, which can be huge and slow, and risks copying things you do not want in the image — the .git directory, local virtual environments, secrets, test data, caches. A .dockerignore file excludes these, shrinking the build context and preventing accidental inclusion of sensitive or bulky files. It is the easy companion to a good Dockerfile: faster builds because less is transferred, and safer images because secrets and cruft are kept out. Treat it as mandatory, listing your VCS directory, environments, caches, and anything sensitive, so the context is only what the build genuinely needs.
By default, containers run as root, which means a compromise of your app process is a compromise of root inside the container — a meaningful escalation in an attacker's favor. Production images should create and switch to a non-root user, so the running process has only the privileges it needs:
RUN useradd --create-home appuser
USER appuser
This is a simple, high-value hardening step: if your application is exploited, the attacker lands as an unprivileged user rather than root, limiting what they can do inside the container and making container escapes harder. Running as non-root is a baseline expectation for any production image, and omitting it is a needless gift to an attacker.
A critical rule: secrets must never be baked into an image. Anything copied or set during the build — an API key in an ENV, a credential in a copied file — is embedded in the image layers and recoverable by anyone who pulls it, even if a later layer appears to remove it, because the layer history persists. Configuration and secrets belong at runtime, injected as environment variables or mounted files. For build-time secrets like a private package index token, use Docker's build secrets mechanism, which does not persist them into the final image. The principle mirrors twelve-factor config: the image is a generic artifact, and the secrets that make it specific to an environment are supplied when it runs, never built in.
Django's static files need collecting, and where you do it matters. Running collectstatic during the build bakes the static assets into the image, which is clean and reproducible for serving them from the container or a CDN populated at deploy time. The build stage is the natural place for this, so the final image arrives with static files ready and no build-time steps needed at startup. Decide deliberately how static and media are served — from the container, from object storage, behind a CDN — because it affects both your Dockerfile and your deployment, and getting it wrong leads to missing styles or broken uploads that only appear in production.
The image needs to know how to start, and for Django that means running under a production server like gunicorn (or an ASGI server for async), not the development server, which is single-threaded and insecure for production. Define the run command clearly, configure the appropriate number of workers for the container's resources, and keep startup concerns — like waiting for the database or running migrations — handled deliberately rather than crammed into the image in fragile ways. A clean entrypoint that launches a proper application server with sensible worker settings is what turns the image from "it starts" into "it serves production traffic reliably."
When an orchestrator stops a container it sends SIGTERM to PID 1, waits a grace period (10 seconds by default for docker stop), then sends SIGKILL. With the exec-form CMD, gunicorn is PID 1, receives SIGTERM, stops accepting connections and lets in-flight requests finish. With the shell form (CMD gunicorn myproject.wsgi), PID 1 is /bin/sh, which does not forward the signal; every deploy then waits out the full grace period and kills requests mid-flight. The same applies to entrypoint scripts: if you need one, it must end with exec so the server replaces the shell:
#!/bin/sh
set -eu
if [ "${DJANGO_MIGRATE_ON_START:-0}" = "1" ]; then
python manage.py migrate --noinput
fi
exec "$@"
With ENTRYPOINT ["/srv/app/docker-entrypoint.sh"] and the gunicorn CMD unchanged, "$@" expands to the gunicorn command. Also align the two timeouts: gunicorn's graceful timeout defaults to 30 seconds, longer than docker stop's 10-second default, so either lower it with --graceful-timeout or raise the stop timeout (docker stop -t, or stop_grace_period in Compose) so gunicorn finishes its own shutdown before the hard kill arrives.
Migrate-on-start is shown behind a flag for a reason: with several replicas starting at once, each one races to apply the same migrations. Django takes no global lock around migrate, so two processes can attempt the same schema change and one fails, or a slow migration holds a table lock while replicas time out on their health checks. The more robust pattern is to run migrations as a separate one-off task from the same image before rolling out the new version, for example docker run --rm --env-file /srv/app/.env myproject:1.4.0 python manage.py migrate --noinput, and to write migrations that are backwards compatible with the previous release while both versions are briefly live.
Beyond multi-stage builds, several habits keep images lean. Combine related RUN commands so you create fewer layers and can clean up within a single layer — installing packages and removing their caches in one step, since a cleanup in a later layer does not shrink an earlier one. Use --no-cache-dir with pip so package caches are not left behind. Remove build-only dependencies after they are used. Each of these trims megabytes, and together with the multi-stage structure they produce an image that is a fraction of a naive build's size, which pays off in every push, pull, and deploy throughout the image's life.
A lean image still contains software — the base OS, system libraries, your Python dependencies — any of which may carry known vulnerabilities. Image scanners analyze your built image against vulnerability databases and report what they find, ideally integrated into your build pipeline so a newly-introduced vulnerable component fails the build. Smaller images help here too, because fewer components mean fewer potential vulnerabilities to begin with. Scanning closes the loop on image security: you build lean and hardened, then continuously verify that what you are shipping has no known holes, updating the base image and dependencies when scans flag issues. It turns image security from a one-time effort into ongoing vigilance.
An image you cannot rebuild identically is a liability — when a vulnerability appears or a bug needs investigating, you must be able to reproduce exactly what is running. Pin your base image to a specific version (or digest), pin your Python dependencies to exact versions, and avoid anything in the build that changes between runs. Reproducibility means the image you build today is the image you can rebuild next month, which matters for debugging, security patching, and trust in your pipeline. It is the difference between "we think this is what is running" and "we know exactly what is running and can recreate it," which is where you want to be when something goes wrong.
Understanding Docker's layer model pays off in faster builds and smaller images. Each instruction creates a layer, layers are cached and reused when their inputs are unchanged, and the cache invalidates from the first changed instruction onward — so everything after a change rebuilds. This is why instruction order matters so much, and why a change to your source should not sit before your dependency installation. It also means a file deleted in a later layer still occupies space in the earlier layer where it was added, which is why cleanup must happen in the same instruction as the thing it cleans. Grasping these internals turns Dockerfile authoring from trial and error into deliberate optimization.
Modern Docker builds use BuildKit, which brings real improvements worth adopting. It builds independent stages in parallel, supports build-time secret mounts so credentials are used during a build without being baked into any layer, and offers cache mounts that persist things like a package cache across builds for faster dependency installation. These features make builds both faster and more secure than the classic builder. Enabling BuildKit and using its capabilities — particularly secret mounts for any build-time credential and cache mounts for package managers — is a straightforward upgrade that addresses both the speed and the secret-handling concerns that matter most in a production build pipeline.
For the smallest, most secure final image, some teams go beyond slim base images to distroless ones — images containing only your application and its runtime dependencies, with no shell, no package manager, and no general-purpose OS utilities. This dramatically shrinks the attack surface, because there is nothing for an attacker who lands in the container to use, and reduces the number of components that could carry a vulnerability. The tradeoff is that debugging is harder without a shell. Distroless final images are an advanced hardening step well-suited to security-sensitive deployments, and they push the multi-stage philosophy — ship only what runs — to its logical conclusion.
A production image should know how to report whether it is healthy, and how to be configured at runtime. A health check lets your orchestrator know the container is actually serving, not just running, so it can restart or route around an unhealthy instance. Runtime configuration through environment variables — following the principle of building one image and configuring it per environment — keeps the image generic and reusable across staging and production. Together these make the image a well-behaved citizen of whatever platform runs it: observable through its health status and adaptable through its environment, rather than a black box baked for a single environment that cannot report its own state.
The slim image has no curl, and installing it only for health checks adds attack surface. Python itself is enough:
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=4)"]
urlopen raises on connection errors and on any 4xx or 5xx response, so the command exits non-zero exactly when the check should fail. The trap is that the probe arrives with Host: 127.0.0.1:8000, which is usually not in ALLOWED_HOSTS, so Django answers 400 Bad Request and the container is marked unhealthy even though it serves real traffic fine. With SECURE_SSL_REDIRECT = True the probe gets redirected to HTTPS instead, which the container does not speak. A small middleware placed first in MIDDLEWARE answers the probe before any host validation or redirect happens:
from django.http import HttpResponse
class HealthCheckMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path == "/healthz":
return HttpResponse("ok", content_type="text/plain")
return self.get_response(request)
This works because request.path does not consult the Host header; host validation happens only when something calls request.get_host(), which later middleware does. Keep this endpoint a liveness check — "the process can serve a request" — and do not make it query the database. If the database has a brief outage, a health check that depends on it makes every replica unhealthy at once and the orchestrator restarts them all, turning a database blip into a full outage. Readiness checks that include dependencies belong on a separate path used for routing decisions, not restarts. Also note that Kubernetes ignores the Dockerfile HEALTHCHECK and uses its own liveness and readiness probes, which should point at the same endpoints.
Most multi-stage problems come from something being present in one stage and missing in another, or from the switch to a non-root user. The table below maps common symptoms to their usual causes:
| Symptom | Likely cause | Fix |
|---|---|---|
pg_config executable not found during pip install | Building psycopg from source without PostgreSQL headers | Install libpq-dev in the builder, or use the binary wheel |
ImportError: libpq.so.5 (or another .so) at startup | Extension compiled in the builder, but its runtime library is missing in the final stage | Install the matching runtime package (libpq5) in the final stage |
ModuleNotFoundError for an installed package | Packages installed with --user under /root, app runs as non-root; or PATH not pointing at the venv | Install into /opt/venv and set PATH in the final stage |
| Containers take exactly 10 seconds to stop | Shell-form CMD or entrypoint without exec; SIGTERM never reaches gunicorn | Use exec form and exec "$@" |
| Health check fails but the site works | Probe blocked by ALLOWED_HOSTS or HTTPS redirect | Answer /healthz in middleware placed first |
When the cause is not obvious, build only the stage in question with --target builder and start a shell in it to inspect what was installed and where; comparing pip list and ls /opt/venv/lib between the builder and the final image usually reveals the gap in a minute. Adding --progress=plain to the build shows full command output, which the default compact view hides.
A production Django image should be lean, fast to build, and hardened, and multi-stage builds are the foundation: build with the full toolchain in one stage, then copy only the finished artifacts into a clean final image, leaving the compilers and build machinery behind. Choose a slim base pinned to a version, order your Dockerfile so dependencies install before code is copied to maximize layer caching, and use .dockerignore to keep the context small and secrets out. Run as a non-root user, never bake secrets into layers, handle static files and the gunicorn entrypoint deliberately, and minimize size by combining layers and clearing caches. Finally, scan images for vulnerabilities in your pipeline and keep builds reproducible with pinned versions. The Dockerfile is production infrastructure, and these practices turn a bloated, vulnerable default into a small, secure, reproducible artifact that deploys quickly and gives attackers nothing spare to exploit.