Docker & Kubernetes

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1 on PyPI, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; nothing on it differs on Django 5.2 LTS except the tasks worker, because django.tasks only exists from Django 6.0 (on 5.2, run Celery or the django-tasks backport instead). The tools are the versions current on that date: uv 0.12.22, Gunicorn 26.2.0, Uvicorn 0.54.0 with uvicorn-worker 0.4.0, WhiteNoise 6.12.0, psycopg 3.3.6, redis-py 8.1.0 and django-tasks-db 0.13.0, the python:3.13-slim-trixie, postgres:18 and redis:8-alpine images, and Kubernetes 1.37 documentation.

What was run and what was not. The Docker daemon was not available on the machine used for this page, so no image was built and no container was started. Instead every step of the Dockerfile was executed by hand in a scratch copy of the project laid out exactly as the build context (uv sync --locked --no-install-project, uv sync --locked, the build-time collectstatic), and the resulting virtual environment ran the image’s command: Gunicorn with two Uvicorn workers served /livez, /readyz and a WhiteNoise static file, the db_worker process executed an enqueued task, and the health-check tests passed. The Compose file was validated with docker compose config (the CLI works without a daemon); PostgreSQL and Redis themselves were not started, so the scratch runs used SQLite and the local-memory cache. The Kubernetes manifests were parsed with kubectl kustomize but not applied (no cluster, and no schema validator such as kubeconform was installed), so treat them as reviewed, not tested.

Containers do not change how Django works; they change where its settings come from, which process runs it and who decides when it starts and stops. The theory of images, Compose and clusters lives in the Docker Reference and the Kubernetes Reference; this page shows only the parts that are specific to a Django project, using the Bookshelf application that grows across this section: a config settings package, a catalog app, PostgreSQL for data, Redis for the cache and a django.tasks worker that emails moderators when a review arrives (see Tasks Framework).

In a hurry: what goes where

Concern Rule of thumb Section

Building the image

Two stages: uv installs from uv.lock and runs collectstatic in the builder; the runtime stage copies /app and runs as a non-root user.

A multi-stage Dockerfile with uv

Keeping the build context clean

.dockerignore excludes .venv, .env, db.sqlite3, staticfiles/ and VCS data.

.dockerignore

Serving

gunicorn config.asgi:application -k uvicorn_worker.UvicornWorker (or plain Gunicorn on config.wsgi if you have no async code).

The application server: Gunicorn, Uvicorn workers or both

Settings

Everything that differs per environment comes from environment variables; SECRET_KEY has no default.

Settings that come from the environment

Local stack

Compose with db, redis, a one-shot migrate, web and worker, ordered with health conditions.

Docker Compose: PostgreSQL, Redis, migrations, web and worker

Health

/livez touches nothing; /readyz checks the database and cache and answers 503 when they fail.

Health-check endpoints

Cluster

ConfigMap + Secret through envFrom; a web Deployment with probes that send a Host header Django accepts; a Service; a migration Job per release; a worker Deployment.

Kubernetes

The project on this page

The examples assume a uv-managed project (see Getting Started for creating one and Project Structure & Apps for the layout). The dependencies the container needs are declared in pyproject.toml and pinned in uv.lock:

pyproject.toml
[project]
name = "bookshelf"
version = "0.1.0"
description = "Bookshelf"
requires-python = ">=3.12"
dependencies = [
    "django==6.1.*",
    "django-tasks-db>=0.13.0",
    "gunicorn>=26.2.0",
    "psycopg[binary]>=3.3.6",
    "redis>=8.1.0",
    "uvicorn>=0.54.0",
    "uvicorn-worker>=0.4.0",
    "whitenoise>=6.12.0",
]

There is no [build-system] table, so uv treats the project as an application: it installs the dependencies into .venv but does not try to build and install the project itself as a package (uv init --no-package creates such a project; the current uv init default is a packaged project). psycopg[binary] ships a precompiled libpq, which keeps the image free of compilers; if you prefer to build psycopg against the system libpq, see PostgreSQL & Database Backends. redis is needed by Django’s built-in RedisCache backend, and django-tasks-db provides the database-backed django.tasks worker described in A database-backed worker, end to end.

$ uv lock
Resolved 16 packages in 4ms
$ uv run python -W error manage.py check
System check identified no issues (0 silenced).

Settings that come from the environment

An image is built once and promoted unchanged from staging to production, so nothing that differs per environment can be baked into it. Django’s own deployment checklist says the same about the secret key and database credentials. The Bookshelf settings read every such value from an environment variable; SECRET_KEY deliberately has no default, so a container started without one fails at once instead of running with a guessable key. The full treatment of settings layouts, django-environ and secret files is in Settings & Configuration; these are only the lines the containers below rely on:

config/settings.py (container-relevant part)
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]          # no default on purpose
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(",")
CSRF_TRUSTED_ORIGINS = [
    o for o in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if o
]

if os.environ.get("POSTGRES_HOST"):
    DATABASES = {
        "default": {
            "ENGINE": "django.db.backends.postgresql",
            "HOST": os.environ["POSTGRES_HOST"],
            "PORT": os.environ.get("POSTGRES_PORT", "5432"),
            "NAME": os.environ.get("POSTGRES_DB", "bookshelf"),
            "USER": os.environ.get("POSTGRES_USER", "bookshelf"),
            "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
            "CONN_MAX_AGE": int(os.environ.get("DJANGO_CONN_MAX_AGE", "60")),
            "CONN_HEALTH_CHECKS": True,
        }
    }
else:  # local runs without a database server
    DATABASES = {
        "default": {
            "ENGINE": "django.db.backends.sqlite3",
            "NAME": os.environ.get("SQLITE_PATH", BASE_DIR / "db.sqlite3"),
        }
    }

if os.environ.get("REDIS_URL"):
    CACHES = {
        "default": {
            "BACKEND": "django.core.cache.backends.redis.RedisCache",
            "LOCATION": os.environ["REDIS_URL"],
        }
    }

TASKS = {
    "default": {
        "BACKEND": "django_tasks_db.DatabaseBackend",
        "QUEUES": ["default", "emails"],
    }
}

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

# TLS ends at the ingress or load balancer, which sets X-Forwarded-Proto.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG

# Containers log to stdout/stderr; the runtime collects the stream.
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "root": {"handlers": ["console"], "level": "INFO"},
}

WhiteNoiseMiddleware sits directly after SecurityMiddleware in MIDDLEWARE, as its documentation requires; it lets Gunicorn serve the collected static files with far-future cache headers, so a container needs no separate web server for them (details in Static Files & Front-End Assets). CONN_MAX_AGE with CONN_HEALTH_CHECKS keeps a database connection per worker across requests and replaces it if the database restarted underneath, which matters when PostgreSQL is another container that can be recycled; Django 5.1+ can instead use psycopg’s connection pool (see PostgreSQL & Database Backends). SECURE_PROXY_SSL_HEADER is safe only because the proxy in front of the pods always sets or strips X-Forwarded-Proto; never set it when clients can reach Gunicorn directly. Logging to the console is what both Docker and Kubernetes expect (see Logging & Error Reporting).

With these settings, check --deploy leaves only the warnings that belong to the proxy layer (HSTS and the HTTPS redirect) and, in the scratch run, the length of the throw-away key:

$ DJANGO_SECRET_KEY=scratch-only-not-secret uv run python manage.py check --deploy
System check identified some issues:

WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. ...
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. ...
?: (security.W009) Your SECRET_KEY has less than 50 characters, less than 5 unique characters, or it's prefixed with 'django-insecure-' ...

System check identified 3 issues (0 silenced).

Generate a real key with get_random_secret_key() (shown in the Compose and Kubernetes sections) and decide on HSTS and the redirect where TLS is terminated; the options are discussed in Security and Settings & Configuration.

A multi-stage Dockerfile with uv

The image has two jobs that need different things. Building needs uv, its cache and write access to the project directory; running needs only Python, the virtual environment, the code and the collected static files. A multi-stage build keeps the first set out of the final image. Instruction-level background is in Dockerfile Fundamentals and Build Cache and Multi-Stage Builds; the FastAPI equivalent of this file is in FastAPI: Docker & Kubernetes.

Dockerfile
# syntax=docker/dockerfile:1

# ---- Stage 1: build the virtual environment and the static files ----
FROM python:3.13-slim-trixie AS builder

COPY --from=ghcr.io/astral-sh/uv:0.12.22 /uv /uvx /bin/

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

WORKDIR /app

# Dependencies first: this layer is reused until uv.lock or pyproject.toml change.
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

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

# collectstatic needs settings to import, not real secrets: a throw-away key
# exists only for this RUN and is not stored in the image's environment.
RUN DJANGO_SECRET_KEY=collectstatic-only \
    /app/.venv/bin/python manage.py collectstatic --noinput

# ---- Stage 2: the runtime image ----
FROM python:3.13-slim-trixie

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

RUN groupadd --system --gid 10001 bookshelf \
 && useradd --system --uid 10001 --gid bookshelf --no-create-home bookshelf

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

USER 10001:10001
EXPOSE 8000

CMD ["gunicorn", "config.asgi:application", \
     "--worker-class", "uvicorn_worker.UvicornWorker", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "2", \
     "--no-control-socket", \
     "--access-logfile", "-"]

What each part does, and what is Django-specific about it:

  • Same base in both stages. The virtual environment’s python is a link to the interpreter of the stage that created it, so the runtime stage must use the same Python image (python:3.13-slim-trixie here; any of 3.12-3.14 is supported by Django 6.1). UV_PYTHON_DOWNLOADS=0 stops uv from downloading its own interpreter, which would not exist in the second stage — the uv guide’s multi-stage example sets it for exactly this reason.

  • uv from its official image, pinned. COPY --from=ghcr.io/astral-sh/uv:0.12.22 copies the two static binaries; they never reach the runtime stage. Pin the version you tested (optionally by digest).

  • Two uv sync layers. The first installs only the dependencies from uv.lock (bind-mounted, so they are not copied into the layer) and is cached until the lock changes; the second adds the project. --locked fails the build when uv.lock is out of date with pyproject.toml, and UV_NO_DEV=1 leaves development dependency groups (pytest, the debug toolbar) out. UV_COMPILE_BYTECODE=1 compiles .pyc files at build time, which shortens container start-up; PYTHONDONTWRITEBYTECODE=1 then stops the runtime from trying to write more into a read-only file system.

  • collectstatic at build time. Static files are part of the release, so they are collected once into /app/staticfiles (STATIC_ROOT) and shipped in the image; every replica then serves identical, hashed file names from WhiteNoise’s manifest. The command has to import the settings, and the settings refuse to load without DJANGO_SECRET_KEY, hence the dummy value scoped to that single RUN. It is not a secret and is not in the image’s configuration (ENV), but it does appear in the build history, which is why it must never be a real key. Anything else collectstatic touches at import time (a database connection in an AppConfig.ready(), for example) would fail here too — one more reason to keep ready() free of I/O (see Project Structure & Apps). If static files go to object storage instead, run collectstatic as a release step with real credentials rather than in the build.

  • Non-root user. The runtime runs as UID/GID 10001 with no login shell or home directory, and the application files stay owned by root, so a compromised process cannot rewrite the code or the static files. Using a numeric USER lets Kubernetes verify runAsNonRoot without reading /etc/passwd. Django itself needs no writable directory in this setup; uploaded media must go to a volume or object storage anyway (see File Uploads & Storage).

  • Exec-form CMD. The JSON array makes Gunicorn PID 1, so SIGTERM from docker stop or the kubelet reaches it directly and it shuts its workers down gracefully. PYTHONUNBUFFERED=1 makes log lines appear immediately.

  • --no-control-socket. Gunicorn 25.1 added a control socket for its gunicornc tool, created by default under $XDG_RUNTIME_DIR or $HOME/.gunicorn/. The bookshelf user has no home directory, and in the scratch run with an unwritable HOME Gunicorn logged Control server error: [Errno 30] Read-only file system (it kept serving). In a container the socket is not needed, so it is switched off.

More hardening (pinning base images by digest, scanning, SBOMs, distroless bases) is covered in Image Best Practices and Supply-Chain Security and Docker Security; BuildKit cache mounts in BuildKit and Buildx.

Replaying the build steps in a copy of the build context (the daemon was unavailable, see the introduction) gives:

$ UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_NO_DEV=1 UV_PYTHON_DOWNLOADS=0 uv sync --locked --no-install-project
...
 + whitenoise==6.12.0
$ UV_NO_DEV=1 uv sync --locked
Checked 15 packages in 0.97ms
$ DJANGO_SECRET_KEY=collectstatic-only .venv/bin/python manage.py collectstatic --noinput
130 static files copied to '<build context>/staticfiles', 390 post-processed.

The 390 post-processed files are the hashed and compressed copies that CompressedManifestStaticFilesStorage writes.

.dockerignore

COPY . /app copies the whole build context, so whatever is not excluded ends up in the image and invalidates the code layer whenever it changes. For a Django project the important exclusions are the local virtual environment (built for your machine, possibly another OS or CPU), the .env file with real secrets, the development SQLite database, a local staticfiles/ directory that would mask the one collectstatic builds, and uploaded media:

.dockerignore
.git
.venv
__pycache__/
*.py[cod]
.env
.env.*
db.sqlite3
staticfiles/
media/
.pytest_cache/
.coverage
compose*.yaml
k8s/
Dockerfile
.dockerignore
*.log

The uv guide calls out .venv specifically: the project environment depends on the local platform and must be created from scratch in the image. With this file the copied context of the scratch project was exactly .python-version, catalog/, config/, manage.py, pyproject.toml and uv.lock. Excluding .env.* also excludes .env.example; keep that template in the repository for people, not in the image.

The application server: Gunicorn, Uvicorn workers or both

Deployment compares every WSGI and ASGI server in depth, covers the reverse proxy, HTTPS headers and the deployment checklist; this section only picks what the image runs.

Django ships two entry points, config/wsgi.py and config/asgi.py, and its deployment how-tos cover Gunicorn (WSGI), and Uvicorn, Daphne and Hypercorn (ASGI). Which one the container runs depends on the code, not on the container:

Command Use when Notes

gunicorn config.wsgi:application

The project is fully synchronous.

Sync or gthread workers; the simplest and most battle-tested option.

gunicorn config.asgi:application -k uvicorn_worker.UvicornWorker

There are async views, streaming responses or long-polling.

Gunicorn manages processes (restarts, signals, --workers); Uvicorn runs the event loop in each. The worker class comes from the separate uvicorn-worker package: Uvicorn’s own uvicorn.workers module is deprecated.

uvicorn config.asgi:application --workers N

You want one process manager fewer.

Uvicorn has its own multi-process mode; in Kubernetes one process per container with more replicas is equally valid.

gunicorn config.asgi:application -k asgi

Trying Gunicorn’s own ASGI worker.

Native ASGI worker added as a beta in Gunicorn 24.0.0 (2026-01-23); it served this page’s endpoints correctly, but the Uvicorn worker remains the documented choice for Django.

The Bookshelf image uses the second row because the section’s later pages add async views (see Async Support); WebSockets need Channels and an ASGI server too (Channels & WebSockets). Running the image’s command against the scratch build:

$ gunicorn config.asgi:application --worker-class uvicorn_worker.UvicornWorker \
    --bind 127.0.0.1:47671 --workers 2 --no-control-socket --access-logfile -
[INFO] Starting gunicorn 26.2.0
[INFO] Using worker: uvicorn_worker.UvicornWorker
[INFO] Booting worker with pid: 33017
[INFO] Booting worker with pid: 33018
[INFO] Waiting for application startup.
[INFO] ASGI 'lifespan' protocol appears unsupported.
[INFO] Application startup complete.
$ curl -s -w ' [%{http_code}]\n' http://localhost:47671/livez
{"status": "alive"} [200]
$ curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost:47671/static/admin/css/base.css
200 text/css; charset="utf-8"

The lifespan line is expected: Django’s ASGI handler does not implement the ASGI lifespan protocol, so there is no startup or shutdown hook to run there; do one-off start-up work (migrations, cache warming) in a separate step, as the next sections do.

How many workers. Gunicorn’s classic starting point is (2 x CPU cores) + 1 per host; inside a container the "host" is the CPU limit or request you give the pod, which is usually one or two cores, so two workers is a sensible default. Scale out with more replicas rather than more workers per container, so that the orchestrator sees each unit of capacity. Each worker keeps its own database connection (CONN_MAX_AGE), so workers x replicas must stay below PostgreSQL’s max_connections (or put PgBouncer or the psycopg pool in between).

Graceful shutdown. On SIGTERM Gunicorn stops accepting connections and gives workers --graceful-timeout seconds (default 30) to finish in-flight requests before killing them; the scratch run shut down cleanly (Worker (pid:35299) was sent SIGTERM!, Shutting down: Master). Kubernetes' terminationGracePeriodSeconds must be longer than that, as the Deployment below sets.

Docker Compose: PostgreSQL, Redis, migrations, web and worker

For local development and small single-host deployments, Compose runs the whole Bookshelf stack from one file. Compose itself is covered in Compose Fundamentals, Compose in Practice and Compose for Local Development; what is Django-specific is the shape of the services: the same image runs three different commands (a one-shot migrate, the web server and the tasks worker), and they must start in the right order.

flowchart LR db[(db: postgres 18)] -->|service_healthy| migrate[migrate: manage.py migrate] migrate -->|service_completed_successfully| web[web: gunicorn + uvicorn workers] migrate -->|service_completed_successfully| worker[worker: manage.py db_worker] redis[(redis: redis 8)] -->|service_healthy| web web -->|SQL| db web -->|cache| redis worker -->|claims tasks| db
compose.yaml
name: bookshelf

x-django: &django
  build: .
  image: bookshelf:dev
  env_file:
    - path: .env
      required: true
  environment:
    POSTGRES_HOST: db
    REDIS_URL: redis://redis:6379/0
    DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1

services:
  db:
    image: postgres:18
    env_file:
      - path: .env
        required: true
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

  redis:
    image: redis:8-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  migrate:
    <<: *django
    command: ["python", "manage.py", "migrate", "--noinput"]
    restart: "no"
    depends_on:
      db:
        condition: service_healthy

  web:
    <<: *django
    ports:
      - "8000:8000"
    depends_on:
      migrate:
        condition: service_completed_successfully
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "python", "-c",
             "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/livez', timeout=2)"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 10s

  worker:
    <<: *django
    command: ["python", "manage.py", "db_worker", "--queue-name", "*"]
    depends_on:
      migrate:
        condition: service_completed_successfully

volumes:
  pgdata:

The secrets live in a .env file next to compose.yaml, which is listed in .gitignore and .dockerignore; the repository keeps only a template:

.env.example
# Copy to .env (never commit .env). Generate the key with:
#   python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
DJANGO_SECRET_KEY=change-me
POSTGRES_DB=bookshelf
POSTGRES_USER=bookshelf
POSTGRES_PASSWORD=change-me

The same variable names serve both sides: the postgres image reads POSTGRES_DB, POSTGRES_USER and POSTGRES_PASSWORD to create the database on first start, and the Django settings read them to connect. $$ in the health check escapes Compose’s own interpolation so that the shell inside the container expands the variables.

Points worth knowing:

  • Ordering by health, not by start. Plain depends_on only waits for a container to be started, and PostgreSQL accepts connections a few seconds later. condition: service_healthy waits for pg_isready to pass, and condition: service_completed_successfully makes web and worker wait until migrate has exited with status 0 — if a migration fails, the stack stops there instead of serving a half-migrated schema.

  • One image, three commands. The x-django extension field and the <<: *django YAML merge share build, image and environment between migrate, web and worker; only command differs. All three build from the same Dockerfile, so the build cache makes the second and third builds immediate.

  • PostgreSQL 18 data directory. From the 18 images on, PGDATA is version specific (/var/lib/postgresql/18/docker) and the image’s declared VOLUME moved to /var/lib/postgresql, which is where the image documentation says mounts should now go. Older tutorials (and images 17 and below) mount /var/lib/postgresql/data; with 18 that path is not where the data is written, so the database would end up in the image’s anonymous volume and be lost when the container is re-created.

  • The worker. db_worker comes from django-tasks-db and polls the database for queued django.tasks tasks; --queue-name '*' serves every queue (the default is only default, a pitfall described in Tasks Framework). With Celery instead, the worker service would run celery -A config worker against Redis as the broker (see Celery Integration).

  • The web health check uses Python’s urllib, because the slim image has no curl.

  • Development variant. For live reload, override web in a compose.override.yaml with a bind mount of the source, DJANGO_DEBUG=1 and python manage.py runserver 0.0.0.0:8000, or use docker compose watch (the uv guide shows a develop.watch section that syncs the code and rebuilds on uv.lock changes). Keep runserver out of the production image’s command: Django’s documentation states it is not meant for production.

Validating the file (the CLI resolves interpolation and merges without a daemon) and starting the stack:

$ cp .env.example .env    # then put real values in .env
$ docker compose config -q && echo valid
valid
$ docker compose up --build -d
$ docker compose ps
$ curl -s http://localhost:8000/readyz

Only docker compose config was run for this page (see the introduction); the resolved worker service it printed shows the merge at work:

  worker:
    build:
      context: /.../bookshelf
      dockerfile: Dockerfile
    command:
      - python
      - manage.py
      - db_worker
      - --queue-name
      - '*'
    depends_on:
      migrate:
        condition: service_completed_successfully
        required: true
    environment:
      DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1
      DJANGO_SECRET_KEY: change-me
      POSTGRES_DB: bookshelf
      POSTGRES_HOST: db
      POSTGRES_PASSWORD: change-me
      POSTGRES_USER: bookshelf
      REDIS_URL: redis://redis:6379/0
    image: bookshelf:dev

Note that docker compose config prints the secrets from .env in clear text; do not paste its output into CI logs or tickets with real values in it.

Health-check endpoints

Django has no built-in health endpoint, and it does not need a package for one: two small views answer the two questions an orchestrator asks. Liveness — "is this process able to answer at all?" — must not depend on anything outside the process: if it checked PostgreSQL, a database outage would make every web container fail liveness, get restarted, and add reconnection load to a database that is already struggling. Readiness — "should this instance receive traffic right now?" — may check the backing services this instance needs, so that an instance that cannot reach them is taken out of the load balancer instead of returning errors.

catalog/health.py
from django.core.cache import cache
from django.db import connection
from django.http import JsonResponse
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET


@never_cache
@require_GET
def livez(request):
    """Liveness: the process answers. No database, no cache."""
    return JsonResponse({"status": "alive"})


@never_cache
@require_GET
def readyz(request):
    """Readiness: this pod can serve traffic right now."""
    checks = {}
    try:
        with connection.cursor() as cursor:
            cursor.execute("SELECT 1")
        checks["database"] = "ok"
    except Exception:
        checks["database"] = "unavailable"
    try:
        cache.set("readyz", "1", timeout=5)
        checks["cache"] = "ok" if cache.get("readyz") == "1" else "unavailable"
    except Exception:
        checks["cache"] = "unavailable"
    ok = all(v == "ok" for v in checks.values())
    return JsonResponse(
        {"status": "ready" if ok else "unavailable", "checks": checks},
        status=200 if ok else 503,
    )
config/urls.py
from django.contrib import admin
from django.urls import path

from catalog import health

urlpatterns = [
    path("livez", health.livez, name="livez"),
    path("readyz", health.readyz, name="readyz"),
    path("admin/", admin.site.urls),
]

Design notes:

  • Where they sit in the request cycle. The probes are ordinary URLs, so every request goes through the whole middleware stack first (see The Request/Response Cycle). Two middlewares matter: CommonMiddleware validates the Host header against ALLOWED_HOSTS and answers 400 when it does not match — which is exactly what happens to Kubernetes probes unless they send an accepted Host (see Probes and the ALLOWED_HOSTS trap) — and SecurityMiddleware with SECURE_SSL_REDIRECT = True would answer plain-HTTP probes with a 301 redirect to HTTPS, so the probe would no longer be testing the view. If you enable the redirect in Django rather than at the proxy, exempt the probe paths with SECURE_REDIRECT_EXEMPT = [r"^livez$", r"^readyz$"].

  • never_cache and require_GET. Health answers must never be served from a cache in front of the app, and a probe has no business changing state; a POST gets 405.

  • Cheap and bounded. Readiness runs every few seconds on every replica. SELECT 1 and a cache round trip are the cheapest real checks; do not count rows or call third-party APIs here — a slow partner should degrade one feature, not remove every pod from the Service.

  • Sync or async. The views are synchronous on purpose: under the ASGI server Django runs them in its thread pool, and the ORM’s synchronous cursor is the straightforward way to test a connection. An async version would need sync_to_async around the cursor anyway (see Async Support).

  • What not to expose. The JSON names the failing dependency but not hostnames, versions or error text; keep it that way, since the endpoints are reachable by anyone who can reach the Service.

  • Packages. django-health-check offers pluggable checks (database, cache, storage, Celery) if you need more than this; the two views above cover what the probes require.

The tests pin the important property — liveness performs no queries — and simulate a database outage by patching the cursor:

catalog/tests.py
from unittest import mock

from django.db.utils import OperationalError
from django.test import TestCase, override_settings


@override_settings(ALLOWED_HOSTS=["testserver"])
class HealthTests(TestCase):
    def test_liveness_never_touches_the_database(self):
        with self.assertNumQueries(0):
            response = self.client.get("/livez")
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {"status": "alive"})
        self.assertIn("no-cache", response["Cache-Control"])

    def test_readiness_ok(self):
        response = self.client.get("/readyz")
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json()["checks"], {"database": "ok", "cache": "ok"})

    def test_readiness_fails_when_database_is_down(self):
        with mock.patch(
            "catalog.health.connection.cursor",
            side_effect=OperationalError("connection refused"),
        ):
            response = self.client.get("/readyz")
        self.assertEqual(response.status_code, 503)
        self.assertEqual(response.json()["checks"]["database"], "unavailable")

    def test_probes_are_get_only(self):
        self.assertEqual(self.client.post("/livez").status_code, 405)
$ uv run python manage.py test catalog
Found 4 test(s).
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.038s

OK

And against the running server, first healthy and then with the database made unreachable (the SQLite path pointed at a missing directory, standing in for a PostgreSQL outage):

$ curl -s -w ' [%{http_code}]\n' http://localhost:47671/readyz
{"status": "ready", "checks": {"database": "ok", "cache": "ok"}} [200]

$ SQLITE_PATH=/nonexistent/dir/db.sqlite3 gunicorn config.wsgi:application --no-control-socket --bind 127.0.0.1:47670 &
$ curl -s -w ' [%{http_code}]\n' http://127.0.0.1:47670/livez
{"status": "alive"} [200]
$ curl -s -w ' [%{http_code}]\n' http://127.0.0.1:47670/readyz
{"status": "unavailable", "checks": {"database": "unavailable", "cache": "ok"}} [503]

Liveness stays green while readiness turns red: exactly the split that keeps an outage from turning into a restart storm.

Kubernetes

On a cluster the same image runs as three workloads: a web Deployment behind a Service, a worker Deployment, and a Job that applies migrations once per release. Configuration and secrets reach all three through the same environment variables the settings already read. The objects themselves are explained in the Kubernetes Reference — ConfigMaps, Secrets, ReplicaSets and Deployments, Services, Health Probes and Lifecycle and Jobs and CronJobs — and a complete multi-service walk-through is in Bookshelf End to End. PostgreSQL and Redis are assumed to exist as Services named bookshelf-db and bookshelf-redis (a managed database is the usual production choice; running one in-cluster is covered in Stateful Applications in Practice).

flowchart TB cm[ConfigMap bookshelf-config] --> web sec[Secret bookshelf-secrets] --> web cm --> worker sec --> worker cm --> job sec --> job job[Job bookshelf-migrate: manage.py migrate] -->|schema| db[(bookshelf-db)] svc[Service bookshelf-web :80] --> web[Deployment bookshelf-web: 3 pods, probes /livez /readyz] web --> db web --> redis[(bookshelf-redis)] worker[Deployment bookshelf-worker: db_worker] --> db

ConfigMap and Secret

Non-sensitive settings go into a ConfigMap; the key, the database credentials and anything else that would hurt if leaked go into a Secret. Both are injected with envFrom, so every key becomes an environment variable with the same name.

k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: bookshelf-config
data:
  DJANGO_ALLOWED_HOSTS: "bookshelf.example.com,localhost"
  DJANGO_CSRF_TRUSTED_ORIGINS: "https://bookshelf.example.com"
  POSTGRES_HOST: "bookshelf-db"
  POSTGRES_DB: "bookshelf"
  REDIS_URL: "redis://bookshelf-redis:6379/0"
k8s/secret.yaml (shape only — create the real one out of band)
# kubectl create secret generic bookshelf-secrets \
#   --from-literal=DJANGO_SECRET_KEY="$(python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())')" \
#   --from-literal=POSTGRES_USER=bookshelf --from-literal=POSTGRES_PASSWORD='...'
apiVersion: v1
kind: Secret
metadata:
  name: bookshelf-secrets
type: Opaque
stringData:
  DJANGO_SECRET_KEY: "replace-me"
  POSTGRES_USER: "bookshelf"
  POSTGRES_PASSWORD: "replace-me"

Django-specific points:

  • localhost in ALLOWED_HOSTS is there for the probes (next section), not for users.

  • CSRF_TRUSTED_ORIGINS needs the scheme (https://…​;) since Django 4.0, and is required for HTTPS form posts from the public host when TLS is terminated before Django.

  • Secrets are only base64-encoded in the API unless the cluster encrypts them at rest, and anyone who can create a pod in the namespace can read them; keep the YAML above out of Git with real values, and prefer an external secret store or sealed/encrypted manifests (see Secrets).

  • Rotating SECRET_KEY without logging every user out uses SECRET_KEY_FALLBACKS (Django 4.1+): put the new key in DJANGO_SECRET_KEY, the old one in a fallback variable read into SECRET_KEY_FALLBACKS, roll the Deployment, and drop the fallback later (see Settings & Configuration).

  • Environment variables are read at start-up. Changing a ConfigMap or Secret does not change running pods; restart them (kubectl rollout restart deployment/bookshelf-web) or include a hash of the configuration in the pod template.

The web Deployment and Service

k8s/deployment-web.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookshelf-web
  labels:
    app.kubernetes.io/name: bookshelf
    app.kubernetes.io/component: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: bookshelf
      app.kubernetes.io/component: web
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    metadata:
      labels:
        app.kubernetes.io/name: bookshelf
        app.kubernetes.io/component: web
    spec:
      terminationGracePeriodSeconds: 40
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: web
          image: registry.example.com/bookshelf:1.4.0
          ports:
            - name: http
              containerPort: 8000
          envFrom:
            - configMapRef:
                name: bookshelf-config
            - secretRef:
                name: bookshelf-secrets
          args: ["gunicorn", "config.asgi:application",
                 "--worker-class", "uvicorn_worker.UvicornWorker",
                 "--bind", "0.0.0.0:8000", "--workers", "2",
                 "--graceful-timeout", "30",
                 "--no-control-socket", "--access-logfile", "-"]
          startupProbe:
            httpGet:
              path: /livez
              port: http
              httpHeaders:
                - name: Host
                  value: localhost
            periodSeconds: 2
            failureThreshold: 30
          livenessProbe:
            httpGet:
              path: /livez
              port: http
              httpHeaders:
                - name: Host
                  value: localhost
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /readyz
              port: http
              httpHeaders:
                - name: Host
                  value: localhost
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 2
          lifecycle:
            preStop:
              sleep:
                seconds: 5
          resources:
            requests:
              cpu: 250m
              memory: 256Mi
            limits:
              memory: 512Mi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}
k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: bookshelf-web
spec:
  selector:
    app.kubernetes.io/name: bookshelf
    app.kubernetes.io/component: web
  ports:
    - name: http
      port: 80
      targetPort: http

The Service only sends traffic to pods whose readiness probe passes. Public traffic and TLS come in through an Ingress or the Gateway API (Ingress, Gateway API), which is also what sets the X-Forwarded-Proto header that SECURE_PROXY_SSL_HEADER trusts.

Probes and the ALLOWED_HOSTS trap

The three probes map onto the two endpoints: startupProbe and livenessProbe call /livez, readinessProbe calls /readyz. The startup probe gives a slow start (importing a large project, warming caches) up to 30 x 2 = 60 seconds before liveness takes over, without a long initialDelaySeconds that would also delay recovery. Any status from 200 to 399 is a success, so the 503 from /readyz takes the pod out of the Service without restarting it, while a hung process that stops answering /livez is restarted.

The Django-specific trap is the Host header. An HTTP probe connects to the pod IP, and unless told otherwise sends that address as the Host header — the Pod API reference notes that the host field defaults to the pod IP and that you "probably want to set Host in httpHeaders instead". Django validates Host against ALLOWED_HOSTS and answers 400, so with ALLOWED_HOSTS = ["bookshelf.example.com"] every probe fails and the pods never become ready. Reproduced against the scratch server with DJANGO_ALLOWED_HOSTS=bookshelf.example.com,localhost:

$ curl -s -o /dev/null -w 'Host pod IP: %{http_code}\n' -H 'Host: 10.244.1.7:8000' http://127.0.0.1:47672/livez
Host pod IP: 400
$ curl -s -o /dev/null -w 'Host localhost: %{http_code}\n' -H 'Host: localhost' http://127.0.0.1:47672/livez
Host localhost: 200

and the server log recorded Invalid HTTP_HOST header: '10.244.1.7:8000'. You may need to add '10.244.1.7:8000' to ALLOWED_HOSTS. (shown here with the example address). The fix used above is to send Host: localhost from every probe and keep localhost in ALLOWED_HOSTS. Do not "fix" it with ALLOWED_HOSTS = ["*"]: that disables Django’s protection against host-header attacks for real traffic too. (Adding the pod IP at start-up works as well, but is more moving parts for no gain.)

The rest of the pod spec, from Django’s point of view:

  • Graceful shutdown. When a pod is deleted it is removed from the Service endpoints while SIGTERM is sent, and the two happen concurrently, so a few requests can still arrive after the signal. The preStop sleep delays SIGTERM by five seconds to let the endpoint removal propagate; then Gunicorn gets --graceful-timeout 30 to finish in-flight requests. terminationGracePeriodSeconds: 40 covers both (the grace period includes the preStop time). The sleep action is run by the kubelet, so it works in a slim image without a sleep binary.

  • Rolling updates and migrations. maxUnavailable: 0 keeps full capacity during a rollout, which also means old and new code run side by side for a while, against the same schema. That is why migrations run as a separate step before the rollout, and why they must be backwards compatible with the previous release (next section).

  • Read-only root file system. The image needs nothing writable except a temporary directory: Gunicorn’s workers keep their heartbeat files in the system temp directory, hence the emptyDir at /tmp. Bytecode was compiled at build time and PYTHONDONTWRITEBYTECODE=1 stops Python from trying to write more. Uploaded media must go to object storage (File Uploads & Storage).

  • Non-root and seccomp. runAsNonRoot with the numeric runAsUser matches the image’s USER 10001, and together with dropped capabilities and RuntimeDefault seccomp the pod meets the "restricted" Pod Security Standard (see Pod Security).

  • Resources. Size the memory request from a real worker’s resident memory times --workers; leave the CPU limit off or generous, because CPU throttling shows up as slow requests and failed probes (see Resource Management). Horizontal autoscaling on CPU or request rate is in Autoscaling.

Migrations as a Job

manage.py migrate must run exactly once per release, before the new web pods take traffic. Running it in the web container’s start command looks convenient but is wrong on a cluster: three replicas start three concurrent migrate processes, a failed migration crash-loops every pod, and the readiness gate no longer protects users from a half-migrated schema. An init container has the same concurrency problem. A Job runs it once, retries a bounded number of times, and gives the deploy pipeline a clear success or failure to wait on:

k8s/job-migrate.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: bookshelf-migrate-1-4-0
spec:
  backoffLimit: 2
  activeDeadlineSeconds: 600
  ttlSecondsAfterFinished: 86400
  template:
    metadata:
      labels:
        app.kubernetes.io/name: bookshelf
        app.kubernetes.io/component: migrate
    spec:
      restartPolicy: Never
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: migrate
          image: registry.example.com/bookshelf:1.4.0
          args: ["python", "manage.py", "migrate", "--noinput"]
          envFrom:
            - configMapRef:
                name: bookshelf-config
            - secretRef:
                name: bookshelf-secrets
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]

A release then becomes three commands (or the equivalent steps in your CD tool):

$ kubectl apply -f k8s/job-migrate.yaml
$ kubectl wait --for=condition=complete --timeout=10m job/bookshelf-migrate-1-4-0
$ kubectl set image deployment/bookshelf-web web=registry.example.com/bookshelf:1.4.0
$ kubectl set image deployment/bookshelf-worker worker=registry.example.com/bookshelf:1.4.0

Why it is shaped this way:

  • One Job per release. A Job’s pod template cannot be changed after creation, so the name carries the version, and ttlSecondsAfterFinished cleans old ones up a day later. Helm users get the same effect with a pre-upgrade hook (see Helm); GitOps tools have sync hooks or waves (see GitOps and CI/CD).

  • restartPolicy: Never with backoffLimit: 2 retries a transient failure (the database not yet reachable) in a fresh pod, and gives up after three attempts instead of hammering a broken migration. activeDeadlineSeconds stops a migration that is blocked on a lock.

  • Concurrency is still possible. Two releases started close together would run two Jobs. Django wraps each migration in a transaction on PostgreSQL, but it does not take a global lock across migrate runs, so serialise releases in the pipeline.

  • Expand, then contract. While the rollout runs, pods of the previous release use the new schema. Additive changes (new nullable columns, new tables) are safe; removing or renaming a column the old code still reads is not. Split such changes over two releases, as described in Migrations and, independently of Django, in Schema Evolution.

  • Other one-off commands — createcachetable, a data backfill, prune_db_task_results — follow the same pattern; the periodic ones become CronJobs (see Jobs and CronJobs).

The worker Deployment

The tasks worker is the same image with a different command and no Service, because nothing connects to it: it pulls work from the database.

k8s/deployment-worker.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookshelf-worker
  labels:
    app.kubernetes.io/name: bookshelf
    app.kubernetes.io/component: worker
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: bookshelf
      app.kubernetes.io/component: worker
  template:
    metadata:
      labels:
        app.kubernetes.io/name: bookshelf
        app.kubernetes.io/component: worker
    spec:
      terminationGracePeriodSeconds: 120
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: worker
          image: registry.example.com/bookshelf:1.4.0
          args: ["python", "manage.py", "db_worker", "--queue-name", "*"]
          envFrom:
            - configMapRef:
                name: bookshelf-config
            - secretRef:
                name: bookshelf-secrets
          resources:
            requests:
              cpu: 100m
              memory: 192Mi
            limits:
              memory: 384Mi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]

The worker run in the scratch project (same environment as the image) picked up a task enqueued from another process and stopped cleanly on SIGTERM:

$ python manage.py db_worker --queue-name '*'
Starting worker worker_id=<worker id> queues=*
Task id=<task id> path=catalog.tasks.notify_moderators state=RUNNING
Task id=<task id> path=catalog.tasks.notify_moderators state=SUCCESSFUL
Received Terminated: 15 - shutting down gracefully... (press Ctrl+C again to force)

Points specific to workers:

  • Graceful stop. On SIGTERM the worker finishes the task in hand and exits, so terminationGracePeriodSeconds must exceed your longest task (120 s here). A task cut off by SIGKILL after the grace period is not finished and its result is not recorded as successful; design tasks to be idempotent so that running one again is harmless (see Tasks Framework).

  • Replicas are safe. Several db_worker processes can serve the same queues; each needs a unique worker id, which is generated when --worker-id is not given.

  • Probes. db_worker has no HTTP port, so there is no HTTP probe to add; Kubernetes already restarts a worker whose process exits. If you need hang detection, an exec probe that checks a heartbeat file the worker updates is the usual pattern, at the cost of extra code.

  • No --reload. Its default follows DEBUG, which is off here; reloading can stop a task uncleanly.

  • Scaling by queue depth (rather than CPU) needs an external metric such as KEDA’s PostgreSQL scaler; see Autoscaling.

  • Celery instead. The same Deployment runs celery -A config worker and, for periodic tasks, exactly one celery -A config beat replica (see Celery Integration).

Checking the manifests

kubectl apply --dry-run=client still needs an API server for discovery and schema download, so on a machine without a cluster it fails with connection refused. What does work offline is parsing: listing the six files in a kustomization.yaml and running kubectl kustomize (built into kubectl) rendered all six objects, which catches YAML and structure errors but not misspelled fields. Before relying on the files, run them through a schema validator such as kubeconform, or kubectl apply --dry-run=server against a real or local cluster (kind, minikube); see Kustomize and kubectl Essentials.

$ kubectl kustomize k8s/ | grep -c '^kind:'
6

Pitfalls

  • Running migrate in the web container’s command. Concurrent migrations across replicas, crash loops on failure, and no single place to see whether the release’s schema change worked. Use the Compose migrate service or the Job.

  • Probes rejected by ALLOWED_HOSTS. Pods never become ready and the logs fill with Invalid HTTP_HOST header. Send a Host header from the probes; never answer with ALLOWED_HOSTS = ["*"].

  • Liveness that checks the database. A database blip restarts every pod at once. Liveness checks the process; readiness checks dependencies.

  • Baking secrets into the image. ENV DJANGO_SECRET_KEY=…​ or a copied .env ends up in every registry that holds the image and in its history. Exclude .env in .dockerignore and inject secrets at run time.

  • collectstatic failing at build time because settings require real secrets or ready() hooks touch the database. Give the build a throw-away key and keep import-time code free of I/O.

  • A virtual environment from the host in the image. Without .venv in .dockerignore, COPY . /app overwrites the Linux environment uv sync built with your macOS or Windows one.

  • Mismatched Python between stages. A builder on 3.13 and a runtime on 3.12 (or uv-managed Python in the builder only) leaves .venv/bin/python pointing at nothing.

  • Shell-form CMD. CMD gunicorn …​ runs under /bin/sh -c, the shell receives SIGTERM, Gunicorn does not, and every stop waits for the grace period and then kills in-flight requests.

  • Too many database connections. Workers x threads x replicas, plus the worker Deployment and the migration Job, each hold connections. Size max_connections or add a pooler before scaling replicas.

  • DEBUG=True because the variable was missing. The settings above default to False; never default DEBUG to True in code that ships in an image.

  • runserver as the container command. It is a development server; Django’s documentation says not to use it in production.

  • PostgreSQL 18 volume at the old path (/var/lib/postgresql/data): the data is not where you think it is.

Where the books are outdated

None of the four reference books deploys with containers in the way shown here. Their deployment chapters describe a uWSGI server behind NGINX (Melé), Daphne on a platform-as-a-service (Marani), a first Gunicorn deployment (Vincent) and server topologies in general (Two Scoops); none of them covers Compose health conditions, Kubernetes probes or uv.

  • uWSGI is in maintenance mode; current Django deployments use Gunicorn, Uvicorn (directly or as a Gunicorn worker), Daphne or Granian — all listed in Django 6.1’s deployment how-tos.

  • Secrets in settings.py, DEBUG = True and ALLOWED_HOSTS = ['*'] in production examples (found across the books) are exactly what an image must not contain; settings come from the environment and check --deploy must be clean apart from deliberate, documented exceptions.

  • requirements.txt plus pip install in a Dockerfile still works, but uv with a committed uv.lock gives reproducible, hash-checked, cached installs and a build that fails when the lock is stale.

  • uvicorn.workers.UvicornWorker, which many guides still copy, is deprecated in favour of the uvicorn-worker package’s uvicorn_worker.UvicornWorker.

  • Running manage.py migrate && gunicorn …​ as the container command (a common pattern in Compose-era tutorials) does not survive more than one replica; use a separate step.

What changed recently

Table 1. Container-relevant changes by version
Version Change

Django 4.0 (2021-12-07)

CSRF_TRUSTED_ORIGINS values must include the scheme (https://bookshelf.example.com).

Django 4.0 (2021-12-07)

Built-in RedisCache backend, so Redis caching needs only redis-py, no third-party cache package.

Django 4.1 (2022-08-03)

SECRET_KEY_FALLBACKS allows rotating the key through a rolling update without invalidating sessions.

Django 4.2 LTS (2023-04-03)

psycopg 3 support (psycopg[binary] in the image above); STORAGES setting introduced.

Django 5.1 (2024-08-07)

Native PostgreSQL connection pooling via psycopg’s pool ("OPTIONS": {"pool": True}), an alternative to CONN_MAX_AGE. STATICFILES_STORAGE removed in favour of STORAGES.

Django 6.0 (2025-12-03)

The Tasks framework (django.tasks); a worker container needs a third-party backend such as django-tasks-db.

Django 6.1 (2026-09-02)

PostgreSQL 14 support dropped (15+); calendar versioning announced (RemovedInDjango2028Warning).

Gunicorn 24.0.0 (2026-01-23) / 25.1.0 (2026-02-13)

Native ASGI worker (beta) / control socket on by default (--no-control-socket to disable).

postgres image 18

PGDATA became version specific and the declared volume moved to /var/lib/postgresql.

Uvicorn

uvicorn.workers deprecated; use the uvicorn-worker package.