CodeSapient
← All articles

Django Docker Production Setup: uv, Gunicorn and a Dockerfile That Holds Up

A production Django Docker setup with a multi-stage uv Dockerfile, Gunicorn, WhiteNoise and env-based settings, plus four failures we reproduced, including Django 6.1's new MAILERS check.

By CodeSapient Labs ·
Stencilled title Django in Docker on a navy corrugated shipping-container wall, with an orange Stage 1 Builder placard, an arrow labelled COPY, a teal Stage 2 Runtime placard and a data plate listing Python 3.14, Django 6.1, Gunicorn 27.0 and uv 0.13

Getting Django to run in a container takes ten minutes. Getting a Django container that survives production takes longer, because the failures show up after the image builds: a health check that returns 400 and gets every task killed, an HTTPS redirect loop behind the load balancer, static files that disappear on the second deploy, or a 900 MB image that takes four minutes to push from CI.

This guide walks through a Django Docker production setup that avoids those traps. It uses a multi-stage Dockerfile, uv for locked dependency installs, Gunicorn as the application server, WhiteNoise for static files, and environment-driven settings. We tested the settings, the build commands and the health check on the box we write these articles from, using Django 6.1.3, Gunicorn 27.0.0, uv 0.13.7 and Python 3.14.10. Where we only read documentation rather than run something, we say so.

What a production Django image actually needs to do

A development container and a production container solve different problems. In development you want a bind mount, auto-reload and every debug tool. In production the image is an artifact: it gets built once in CI, scanned, promoted through environments and started by an orchestrator that knows nothing about Django. That gives the image a short list of jobs:

  • Reproducible dependencies. The same lockfile produces the same packages on every build.
  • No secrets baked in. Configuration comes from the environment at runtime, so one image runs in staging and production.
  • A small runtime surface. No compilers, no package caches, no .git directory, and a non-root user.
  • A real WSGI or ASGI server. Django's deployment checklist is blunt about it: runserver is not designed for production.
  • A health check the platform can trust. One cheap endpoint that answers 200 when the process can serve requests.
  • Logs on stdout. Containers are disposable; log files written inside them disappear with them.

Everything below maps to one of those jobs.

The multi-stage Dockerfile

A multi-stage build splits the work into a builder stage that installs packages and collects static files, and a runtime stage that only receives the finished result. Here is the full file, assuming a project created with uv init, a Django project package called config, and a committed uv.lock.

# syntax=docker/dockerfile:1

# ---------- builder ----------
FROM python:3.14-slim-trixie AS builder
COPY --from=ghcr.io/astral-sh/uv:0.13.7 /uv /uvx /bin/

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_NO_DEV=1 \
    UV_PYTHON_DOWNLOADS=0

WORKDIR /app

# 1. Dependencies only: this layer is cached until uv.lock changes
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --locked --no-install-project

# 2. Application code
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked

# 3. Static files, with a throwaway key that never reaches runtime
RUN DJANGO_SECRET_KEY=build-only-not-secret \
    /app/.venv/bin/python manage.py collectstatic --noinput

# ---------- runtime ----------
FROM python:3.14-slim-trixie

RUN groupadd --system app \
 && useradd --system --gid app --home-dir /app --no-create-home app

WORKDIR /app
COPY --from=builder --chown=app:app /app /app

ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1

USER app
EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
  CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2).status == 200 else 1)"]

CMD ["gunicorn", "config.wsgi:application", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "3", \
     "--access-logfile", "-"]

Pair it with a .dockerignore so local artifacts never enter the build context:

.venv/
__pycache__/
*.pyc
.git/
.env
staticfiles/
media/
node_modules/

Excluding .venv matters more than it looks. uv's Docker guide calls it out explicitly: a virtual environment is platform specific, so a macOS .venv copied into a Linux image is broken in ways that only appear at import time.

Diagram styled as two shipping containers: a rust-red builder stage holding the Python 3.14 slim base, uv binary, uv cache, build-only secret key and collectstatic, and a teal runtime stage receiving the virtual environment, source code, static files, a non-root user and the Gunicorn command via COPY --from=builder
Only the finished virtual environment, code and static files cross into the runtime stage; uv, its cache and the build-only key stay behind.

Why each line is there

Pinned uv image. Copying the binary from ghcr.io/astral-sh/uv:0.13.7 instead of :latest keeps builds repeatable. For stricter supply-chain control you can pin the image digest instead of the tag, which uv's documentation recommends for reproducible builds.

Two uv sync calls. The first installs only third-party dependencies, using bind mounts so the lockfile is read without being copied into a layer. Because your code changes far more often than your dependencies, this layer stays cached and most rebuilds skip the install entirely. The second sync runs after COPY . /app and installs the project itself. --locked makes the build fail if uv.lock is out of date with pyproject.toml, which is exactly what you want in CI.

UV_NO_DEV=1. Test runners, linters and debug toolbars stay out of the production image.

UV_COMPILE_BYTECODE=1. Pre-compiling .pyc files moves work from container start to build time. uv's docs describe the trade-off as faster startup in exchange for longer installs and a slightly larger image. In our local run of the same sync commands, uv reported compiling 1,107 files in under 70 ms for a Django + Gunicorn + WhiteNoise + psycopg dependency set, so the build-time cost is small for a typical app.

The same base image in both stages. This one bites people who try to shrink images further. A virtual environment's python is a symlink to an absolute interpreter path; on our box, .venv/bin/python pointed straight at the interpreter that created it. If the runtime stage uses a different base with Python in a different location, the copied venv will not start. Keep the builder and runtime on the same python:3.14-slim-trixie tag (or digest) and the paths line up.

Non-root user. The runtime stage creates a system user and copies files with --chown. If an attacker finds a remote code execution bug, they land as an unprivileged user inside a filesystem they do not own.

Settings that read the environment

The image should be identical across environments, so every value that differs between staging and production belongs in an environment variable. A minimal pattern in settings.py looks like this:

import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
CSRF_TRUSTED_ORIGINS = [
    o for o in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if o
]

STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"
    },
}

# TLS ends at the load balancer
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [r"^healthz$"]
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = int(os.environ.get("DJANGO_HSTS_SECONDS", "0"))

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "root": {"handlers": ["console"], "level": "INFO"},
}

Using os.environ["DJANGO_SECRET_KEY"] with square brackets is deliberate: a container started without a key should crash immediately rather than boot with a default. That is also why the Dockerfile passes a throwaway key only to the collectstatic step. It exists for one command in the builder stage and is not present in the runtime image's environment.

Add whitenoise.middleware.WhiteNoiseMiddleware directly after SecurityMiddleware, as the WhiteNoise Django guide describes. In our test, WhiteNoise served the hashed copy of an admin stylesheet with Cache-Control: max-age=315360000, public, immutable and the unhashed name with a 60-second cache, which is the behaviour you want: fingerprinted files can be cached forever by a CDN, and a new deploy produces new file names.

Django 6.1 adds one new deploy-time trap: MAILERS

If you start a new project on Django 6.1, startproject now writes a MAILERS setting that uses the console email backend. The Django 6.1 release notes introduce MAILERS as the replacement for EMAIL_BACKEND and the other EMAIL_* settings, along with a new deployment-only check, mail.E001, that rejects development backends in the default entry.

We hit it on our first run of manage.py check --deploy against a fresh 6.1.3 project. It is an error, not a warning, so a CI step that runs the deploy check fails until you configure a real mailer:

MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {
            "host": os.environ.get("SMTP_HOST", "localhost"),
            "port": int(os.environ.get("SMTP_PORT", "587")),
            "username": os.environ.get("SMTP_USER", ""),
            "password": os.environ.get("SMTP_PASSWORD", ""),
            "use_tls": True,
        },
    },
}

With that in place, the error disappeared and only the HSTS-related warnings remained, which you resolve once you are sure every subdomain is HTTPS-only. Existing projects that still use EMAIL_BACKEND keep working on 6.1 but get deprecation warnings, so this is a good moment to migrate.

Gunicorn in a container

Gunicorn is still the default choice for synchronous Django apps. A few container-specific points:

  • Use the exec form of CMD (the JSON array). Gunicorn then runs as PID 1 and receives the orchestrator's SIGTERM directly, so it can finish in-flight requests before the container is stopped. The shell form wraps it in /bin/sh, which may not forward the signal.
  • Size workers to the container, not the host. A common starting point is a small fixed number per container (we use 3 above) and horizontal scaling by adding containers. Formulas based on CPU count can read the host's cores rather than your container's limit, so set the number explicitly.
  • Send access logs to stdout with --access-logfile -, and let the platform collect them.
  • Check the Gunicorn 27 change before upgrading. Gunicorn 27.0.0, released on 10 November 2026, now rejects with 400 any request whose Transfer-Encoding does not end with chunked. Normal browser and API traffic is unaffected, but if you have an internal client that sends Transfer-Encoding: identity, it must stop.

If your app is async-heavy (WebSockets, streaming, many concurrent outbound calls), run an ASGI server instead and point it at config.asgi:application. The rest of the Dockerfile stays the same.

The four failures we reproduced, and how to avoid them

These are the problems that make a "working" image fail in production. We reproduced each one locally against Gunicorn with the settings above.

Four riveted warning placards summarising the lab results: health check returns 400 for an IP Host header, HTTPS redirect returns 301 without X-Forwarded-Proto, static files collected at build time with immutable caching, and the Django 6.1 mail.E001 deploy check error
The four production failures we reproduced locally with Django 6.1.3, Gunicorn 27.0.0 and Python 3.14.10.

1. The health check returns 400

Django validates the Host header against ALLOWED_HOSTS. Load balancers and container health checks often call the container by IP. With ALLOWED_HOSTS=app.example.com, our /healthz request returned 400 when sent with Host: 10.0.0.12, and 200 with the real host name. The orchestrator sees 400, marks the task unhealthy and restarts it, in a loop.

Fixes, in order of preference: configure the platform's health check to send your real host name; add the specific internal address your probe uses (the Dockerfile's HEALTHCHECK calls 127.0.0.1, so include it); or answer health checks in a tiny middleware placed first in MIDDLEWARE, before anything that calls request.get_host(). Avoid ALLOWED_HOSTS = ["*"]; it switches off a protection against host-header attacks.

2. HTTPS redirect loops behind the proxy

When TLS ends at the load balancer, Django sees plain HTTP. With SECURE_SSL_REDIRECT = True and no proxy header configured, every request is redirected to HTTPS, arrives at Django as HTTP again, and loops. In our test, a request without X-Forwarded-Proto to /admin/login/ returned 301, and the same request with X-Forwarded-Proto: https returned 200.

SECURE_PROXY_SSL_HEADER tells Django to trust that header. Only set it when your proxy always sets the header and strips any copy sent by the client; otherwise a client can claim to be on HTTPS. We also exempt /healthz from the redirect so probes that speak plain HTTP inside the network get a straight 200, which our health check script confirmed.

3. Static files vanish or go stale

Running collectstatic at container start, or relying on a shared volume, creates race conditions when several containers start at once. Run it in the builder stage instead, as above. The files ship inside the image, every container has an identical copy, and the manifest storage guarantees templates reference file names that exist. In our build, collectstatic copied 130 admin files and post-processed 390 outputs including compressed variants, all before the runtime stage existed.

User uploads are different: never store media/ inside the container. Use object storage such as S3 through the default storage backend.

4. The deploy check fails in CI

Run python manage.py check --deploy against production settings in CI, inside the built image, before you push it. On Django 6.1 this is where mail.E001 appears for new projects, and it is also where you catch DEBUG = True or a missing SECURE_SSL_REDIRECT before they reach users. Treat errors as build failures and review warnings explicitly.

Migrations: run them once, not per container

The tempting shortcut is an entrypoint script that runs migrate and then starts Gunicorn. With one container that works. With five replicas starting together, five processes race to apply the same migrations, and a slow migration delays every container's health check.

Run migrations as a separate one-off task from the same image instead: a release phase, a Kubernetes Job, an ECS run-task, or a dedicated Compose service with command: python manage.py migrate that the web service waits on. Then make sure each migration is backward compatible with the code that is still running during the rollout. Our guide to zero-downtime database migrations for Django and PostgreSQL covers the expand-and-contract pattern in detail.

Workers, the database and Compose

The same image can run more than one role. Celery workers, Django Tasks workers and scheduled jobs use identical code with a different command, so build once and override CMD per service:

services:
  web:
    image: registry.example.com/myapp:${GIT_SHA}
    env_file: .env.production
    ports: ["8000:8000"]
    depends_on:
      migrate:
        condition: service_completed_successfully

  migrate:
    image: registry.example.com/myapp:${GIT_SHA}
    env_file: .env.production
    command: ["python", "manage.py", "migrate", "--noinput"]

  worker:
    image: registry.example.com/myapp:${GIT_SHA}
    env_file: .env.production
    command: ["celery", "-A", "config", "worker", "--loglevel=info"]

If you are choosing a background job approach, see our posts on Django Celery and Redis and the Django Tasks framework. Once you run several web containers with several Gunicorn workers each, database connections multiply fast; PostgreSQL connection pooling with PgBouncer explains how to keep that under control.

Common mistakes to avoid

  • Copying the whole project before installing dependencies. Every code change invalidates the dependency layer and reinstalls everything.
  • Using uv sync without --locked in CI. The build quietly resolves new versions instead of failing on a stale lockfile.
  • Baking .env into the image. Anyone who can pull the image can read the secrets. Inject them at runtime from your platform's secret store.
  • Mixing base images between stages. The copied virtual environment points to an interpreter path that may not exist in the runtime image.
  • Running as root because it "just works" with file permissions. Fix the ownership with --chown instead.
  • Unpinned tags like python:latest and uv:latest, which change underneath you between builds.
  • Turning on HSTS preload casually. Django's own check warns that careless HSTS can cause serious, hard-to-reverse problems. Start with a short SECURE_HSTS_SECONDS and increase it.

A short production checklist

  1. Multi-stage build on a pinned Python base, with a pinned uv version.
  2. uv sync --locked in two layers: dependencies first, project second.
  3. .dockerignore excludes .venv, .git, .env and local static or media output.
  4. Secrets and hosts come from environment variables; the app fails fast without a secret key.
  5. collectstatic runs at build time with manifest storage and WhiteNoise or a CDN.
  6. Gunicorn (or an ASGI server) via exec-form CMD, explicit worker count, logs to stdout.
  7. A non-root user in the runtime stage.
  8. A /healthz endpoint that works with your ALLOWED_HOSTS and proxy settings.
  9. check --deploy passes in CI, including the Django 6.1 MAILERS check.
  10. Migrations run once per release, as a separate task.

FAQ

Should I use Alpine for a Django image?

You can, but many Python packages publish wheels built for glibc-based Linux first, and musl-based Alpine sometimes means compiling from source or using different wheels. A slim Debian base is the lower-friction default. If image size is the main concern, measure both with your real dependency set before switching.

Do I still need Nginx in front of Gunicorn?

Not necessarily. If a managed load balancer or ingress terminates TLS and WhiteNoise or a CDN serves static files, many teams run Gunicorn directly behind it. A reverse proxy still earns its place when you need request buffering for slow clients, custom caching or serving large uploads.

Can I use pip instead of uv?

Yes. The structure stays the same: install from a lock or hashed requirements file in a builder stage and copy the environment into the runtime stage. We use uv here because its lockfile check and cache mounts make the two-layer pattern simple.

Conclusion

A production-ready Django image is less about clever Dockerfile tricks and more about removing surprises: locked dependencies, no secrets in layers, static files built once, an honest health check, correct proxy settings and migrations that run exactly once. Get those right and the container becomes boring, which is the goal.

If your team is moving a Django or Python application to containers, or untangling a deployment that keeps failing health checks, CodeSapient can help with the Dockerfile, CI pipeline and the infrastructure around it. Take a look at our software development services or get in touch to talk through your setup.

← Back to Blog