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 |
|
Keeping the build context clean |
|
|
Serving |
|
|
Settings |
Everything that differs per environment comes from environment variables; |
|
Local stack |
Compose with |
Docker Compose: PostgreSQL, Redis, migrations, web and worker |
Health |
|
|
Cluster |
ConfigMap + Secret through |
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:
[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).
Source: uv: Using uv in Docker and django-admin check
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:
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"},
}
Source: Deployment checklist, SECURE_PROXY_SSL_HEADER and Redis cache
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).
Source: Django: Deployment checklist
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.
# 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
pythonis 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-trixiehere; any of 3.12-3.14 is supported by Django 6.1).UV_PYTHON_DOWNLOADS=0stops 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.22copies the two static binaries; they never reach the runtime stage. Pin the version you tested (optionally by digest). -
Two
uv synclayers. The first installs only the dependencies fromuv.lock(bind-mounted, so they are not copied into the layer) and is cached until the lock changes; the second adds the project.--lockedfails the build whenuv.lockis out of date withpyproject.toml, andUV_NO_DEV=1leaves development dependency groups (pytest, the debug toolbar) out.UV_COMPILE_BYTECODE=1compiles.pycfiles at build time, which shortens container start-up;PYTHONDONTWRITEBYTECODE=1then stops the runtime from trying to write more into a read-only file system. -
collectstaticat 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 withoutDJANGO_SECRET_KEY, hence the dummy value scoped to that singleRUN. 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 elsecollectstatictouches at import time (a database connection in anAppConfig.ready(), for example) would fail here too — one more reason to keepready()free of I/O (see Project Structure & Apps). If static files go to object storage instead, runcollectstaticas 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
USERlets Kubernetes verifyrunAsNonRootwithout 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, soSIGTERMfromdocker stopor the kubelet reaches it directly and it shuts its workers down gracefully.PYTHONUNBUFFERED=1makes log lines appear immediately. -
--no-control-socket. Gunicorn 25.1 added a control socket for itsgunicornctool, created by default under$XDG_RUNTIME_DIRor$HOME/.gunicorn/. Thebookshelfuser has no home directory, and in the scratch run with an unwritableHOMEGunicorn loggedControl 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.
Source: uv: Using uv in Docker and collectstatic
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:
.git
.venv
__pycache__/
*.py[cod]
.env
.env.*
db.sqlite3
staticfiles/
media/
.pytest_cache/
.coverage
compose*.yaml
k8s/
Dockerfile
.dockerignore
*.log
Source: Docker: .dockerignore files and uv: Using uv in Docker
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 |
|---|---|---|
|
The project is fully synchronous. |
Sync or |
|
There are async views, streaming responses or long-polling. |
Gunicorn manages processes (restarts, signals, |
|
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. |
|
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. |
Source: How to use Django with Uvicorn, How to use Django with Gunicorn, Uvicorn: Deployment and Gunicorn: ASGI Worker
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.
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:
Source: Compose file reference: services, Control startup order, Fragments and postgres official image
The secrets live in a .env file next to compose.yaml, which is listed in .gitignore and .dockerignore; the
repository keeps only a template:
# 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
Source: Compose file reference: env_file and SECRET_KEY
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_ononly waits for a container to be started, and PostgreSQL accepts connections a few seconds later.condition: service_healthywaits forpg_isreadyto pass, andcondition: service_completed_successfullymakeswebandworkerwait untilmigratehas 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-djangoextension field and the<<: *djangoYAML merge share build, image and environment betweenmigrate,webandworker; onlycommanddiffers. 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,
PGDATAis version specific (/var/lib/postgresql/18/docker) and the image’s declaredVOLUMEmoved 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_workercomes fromdjango-tasks-dband polls the database for queueddjango.taskstasks;--queue-name '*'serves every queue (the default is onlydefault, a pitfall described in Tasks Framework). With Celery instead, the worker service would runcelery -A config workeragainst Redis as the broker (see Celery Integration). -
The web health check uses Python’s
urllib, because the slim image has nocurl. -
Development variant. For live reload, override
webin acompose.override.yamlwith a bind mount of the source,DJANGO_DEBUG=1andpython manage.py runserver 0.0.0.0:8000, or usedocker compose watch(the uv guide shows adevelop.watchsection that syncs the code and rebuilds onuv.lockchanges). Keeprunserverout 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
Source: Docker: docker compose config
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.
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,
)
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:
CommonMiddlewarevalidates theHostheader againstALLOWED_HOSTSand answers 400 when it does not match — which is exactly what happens to Kubernetes probes unless they send an acceptedHost(see Probes and the ALLOWED_HOSTS trap) — andSecurityMiddlewarewithSECURE_SSL_REDIRECT = Truewould 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 withSECURE_REDIRECT_EXEMPT = [r"^livez$", r"^readyz$"]. -
never_cacheandrequire_GET. Health answers must never be served from a cache in front of the app, and a probe has no business changing state; aPOSTgets 405. -
Cheap and bounded. Readiness runs every few seconds on every replica.
SELECT 1and 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_asyncaround 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-checkoffers 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:
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)
Source: assertNumQueries() and Overriding settings
$ 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
Source: Django: Running tests
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).
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.
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"
Source: Kubernetes: ConfigMaps
# 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"
Source: Kubernetes: Secrets and SECRET_KEY
Django-specific points:
-
localhostinALLOWED_HOSTSis there for the probes (next section), not for users. -
CSRF_TRUSTED_ORIGINSneeds 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_KEYwithout logging every user out usesSECRET_KEY_FALLBACKS(Django 4.1+): put the new key inDJANGO_SECRET_KEY, the old one in a fallback variable read intoSECRET_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
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: {}
Source: Kubernetes: Deployments, Configure Liveness, Readiness and Startup Probes and Container Lifecycle Hooks
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
Source: Kubernetes: Service
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.)
Source: Pod API reference (HTTPGetAction) and ALLOWED_HOSTS
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
SIGTERMis sent, and the two happen concurrently, so a few requests can still arrive after the signal. ThepreStopsleep delaysSIGTERMby five seconds to let the endpoint removal propagate; then Gunicorn gets--graceful-timeout 30to finish in-flight requests.terminationGracePeriodSeconds: 40covers both (the grace period includes thepreStoptime). Thesleepaction is run by the kubelet, so it works in a slim image without asleepbinary. -
Rolling updates and migrations.
maxUnavailable: 0keeps 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
emptyDirat/tmp. Bytecode was compiled at build time andPYTHONDONTWRITEBYTECODE=1stops Python from trying to write more. Uploaded media must go to object storage (File Uploads & Storage). -
Non-root and seccomp.
runAsNonRootwith the numericrunAsUsermatches the image’sUSER 10001, and together with dropped capabilities andRuntimeDefaultseccomp 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:
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"]
Source: Kubernetes: Jobs and django-admin migrate
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
Source: kubectl wait and kubectl set image
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
ttlSecondsAfterFinishedcleans old ones up a day later. Helm users get the same effect with apre-upgradehook (see Helm); GitOps tools have sync hooks or waves (see GitOps and CI/CD). -
restartPolicy: NeverwithbackoffLimit: 2retries a transient failure (the database not yet reachable) in a fresh pod, and gives up after three attempts instead of hammering a broken migration.activeDeadlineSecondsstops 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
migrateruns, 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.
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"]
Source: Kubernetes: Deployments and django-tasks-db
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)
Source: Tasks: Third-party backends
Points specific to workers:
-
Graceful stop. On
SIGTERMthe worker finishes the task in hand and exits, soterminationGracePeriodSecondsmust exceed your longest task (120 s here). A task cut off bySIGKILLafter 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_workerprocesses can serve the same queues; each needs a unique worker id, which is generated when--worker-idis not given. -
Probes.
db_workerhas no HTTP port, so there is no HTTP probe to add; Kubernetes already restarts a worker whose process exits. If you need hang detection, anexecprobe that checks a heartbeat file the worker updates is the usual pattern, at the cost of extra code. -
No
--reload. Its default followsDEBUG, 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 workerand, for periodic tasks, exactly onecelery -A config beatreplica (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
migratein 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 Composemigrateservice or the Job. -
Probes rejected by
ALLOWED_HOSTS. Pods never become ready and the logs fill withInvalid HTTP_HOST header. Send aHostheader from the probes; never answer withALLOWED_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.envends up in every registry that holds the image and in its history. Exclude.envin.dockerignoreand inject secrets at run time. -
collectstaticfailing at build time because settings require real secrets orready()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
.venvin.dockerignore,COPY . /appoverwrites the Linux environmentuv syncbuilt 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/pythonpointing at nothing. -
Shell-form
CMD.CMD gunicorn …runs under/bin/sh -c, the shell receivesSIGTERM, 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_connectionsor add a pooler before scaling replicas. -
DEBUG=Truebecause the variable was missing. The settings above default toFalse; never defaultDEBUGtoTruein code that ships in an image. -
runserveras 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 = TrueandALLOWED_HOSTS = ['*']in production examples (found across the books) are exactly what an image must not contain; settings come from the environment andcheck --deploymust be clean apart from deliberate, documented exceptions. -
requirements.txtpluspip installin a Dockerfile still works, but uv with a committeduv.lockgives 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 theuvicorn-workerpackage’suvicorn_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
| Version | Change |
|---|---|
Django 4.0 (2021-12-07) |
|
Django 4.0 (2021-12-07) |
Built-in |
Django 4.1 (2022-08-03) |
|
Django 4.2 LTS (2023-04-03) |
psycopg 3 support ( |
Django 5.1 (2024-08-07) |
Native PostgreSQL connection pooling via psycopg’s pool ( |
Django 6.0 (2025-12-03) |
The Tasks framework ( |
Django 6.1 (2026-09-02) |
PostgreSQL 14 support dropped (15+); calendar versioning announced ( |
Gunicorn 24.0.0 (2026-01-23) / 25.1.0 (2026-02-13) |
Native ASGI worker (beta) / control socket on by default ( |
|
|
Uvicorn |
|
Source: Django release notes, Gunicorn changelog and Uvicorn: Deployment