Django Docker Production Checklist

Review a Dockerized Django deployment before production: image build, non-root user, secrets, health checks, volumes, logging, resource limits, and release steps.

Problem statement

Docker makes a Django app easy to run anywhere, and just as easy to run insecurely or unreliably. A container that works on a laptop can still ship with a root user, secrets baked into image layers, the development server as its command, migrations that run in every replica, logs written to files inside the container, and a database volume that disappears with docker compose down -v.

This checklist reviews a Dockerized Django deployment from image build to runtime operations. It applies to Docker Compose on a server and translates directly to Kubernetes, ECS, and other orchestrators.

Production container reviewImageslim, pinned, scannedRuntimenon-root, read-onlyConfigenv and secretsOperationshealth, logs, limitsWork outward from the image to how the container is run and observed.

Quick answer

  1. Image: slim, pinned base image; multi-stage build; .dockerignore; dependencies pinned; static files collected at build.
  2. Runtime process: Gunicorn (or an ASGI server) in the foreground as PID 1 via exec; never runserver.
  3. User: non-root user; read-only root filesystem where possible.
  4. Configuration: all settings and secrets from environment variables or secret mounts, never baked into images.
  5. Health: container health check and orchestrator readiness checks.
  6. Data: database and media on persistent volumes or managed services; backups outside the host.
  7. Networking: only the reverse proxy publishes ports; app and database containers stay internal.
  8. Operations: logs to stdout/stderr with rotation, resource limits, restart policies, migrations as a separate release step, immutable image tags.

Checklist

1. Base image and build

What to check in the Dockerfile:

  • base image pinned to a specific version (for example python:3.13-slim-bookworm, or a digest)
  • multi-stage build if compilers are needed, so they stay out of the runtime image
  • dependencies installed from a lockfile or fully pinned requirements before copying code, for layer caching
  • .dockerignore excludes .git, .env, virtualenvs, node_modules, media, and local databases
  • collectstatic runs during the build with a throwaway key
FROM python:3.13-slim-bookworm

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
RUN DJANGO_SECRET_KEY=build-only python manage.py collectstatic --noinput

RUN useradd --uid 10001 --create-home app && chown -R app:app /app/staticfiles
USER app

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

PYTHONUNBUFFERED=1 ensures logs appear immediately in docker logs.

2. The main process

What to check:

  • CMD runs Gunicorn, Uvicorn, or Daphne, not python manage.py runserver
  • entrypoint scripts end with exec "$@" so the server becomes PID 1 and receives stop signals
  • no --daemon flags or background process managers inside the container
  • worker count sized for the container's CPU and memory limits, not the host's
who is PID 1?CMD gunicorn … (shell form)✕/bin/sh is PID 1✕SIGTERM never reaches Gunicorn✕killed after the stop timeoutCMD ["gunicorn", …] (exec form)✓Gunicorn is PID 1✓graceful shutdown on SIGTERM✓entrypoints end with exec "$@"Graceful shutdown lets in-flight requests finish during deploysdjango-deployment.com
A slow docker stop, about ten seconds every time, is the usual symptom.

Verify:

docker compose exec web ps -o pid,user,args

PID 1 should be the Gunicorn master, running as the non-root user.

3. Non-root and filesystem

What to check:

docker compose exec web id
docker inspect --format '{{.Config.User}}' myapp-web-1

Guidelines:

  • a dedicated UID (for example 10001), not root
  • code files not writable by that user where practical
  • read_only: true for the root filesystem in Compose, with tmpfs for /tmp and volumes for directories that need writes
services:
  web:
    read_only: true
    tmpfs:
      - /tmp
  • no privileged: true; drop capabilities the app does not need (cap_drop: [ALL] works for most Django containers)

4. Configuration and secrets

What to check:

docker history --no-trunc myorg/myapp:1.4.2 | grep -i -E "secret|password|key" || echo "no obvious secrets in history"
docker compose config | grep -i -E "SECRET|PASSWORD" | head

Guidelines:

  • no secrets in ENV or ARG instructions; build arguments can persist in image metadata
  • runtime secrets from env_file with restrictive permissions, Docker or Compose secrets mounted as files, or an orchestrator's secret store
  • .env files excluded from the build context and from version control
  • the Compose .env file (used for variable substitution) not confused with application environment files

5. Health checks

services:
  web:
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz/', timeout=3)"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 30s
a health check that means somethingHEALTHCHECK --interval=30s --timeout=3s--start-period=20s\CMDpython -c"import urllib.request as u;u.urlopen('http://127.0.0.1:8000/healthz/', timeout=2)"1231Failures while booting do not count.2No curl needed in a slim image.3A cheap view that answers before host validation.Orchestrators use this to decide when a new container is readydjango-deployment.com
Keep the database out of this check unless a lost database should restart containers.
  • the health endpoint handles requests to 127.0.0.1 without DisallowedHost errors (answer it before host validation, or allow that host for the health path only)
  • dependencies wait for health: depends_on with condition: service_healthy for the database
  • orchestrators use readiness checks to avoid routing traffic to starting containers

Using Python for the check avoids installing curl in slim images.

6. Database and persistent data

What to check:

docker volume ls
docker compose config | sed -n '/volumes:/,$p'

Guidelines:

  • PostgreSQL data on a named volume or, better for production, a managed database
  • media files in object storage or on a persistent volume, never in the container filesystem
  • database container not publishing port 5432 on public interfaces
  • backups copied off the host; a volume on the same disk is not a backup
  • no docker compose down -v in deploy scripts

7. Networking

Guidelines:

  • only the reverse proxy (Nginx, Traefik, Caddy) publishes ports 80 and 443
  • the Django container is reachable only on the internal network
  • published ports bound to 127.0.0.1 when a host-level proxy is used, because Docker's port publishing bypasses host firewalls such as ufw
publish only the front doorPublished portproxy (Nginx, Caddy)✓80, 443web (Gunicorn)✓none, or 127.0.0.1 onlydb (PostgreSQL)✓noneredis✓noneDocker-published ports bypass ufw rules on many hostsdjango-deployment.com
Services reach each other by name on the Compose network without published ports.
services:
  web:
    ports:
      - "127.0.0.1:8000:8000"
  • ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, and SECURE_PROXY_SSL_HEADER configured for the proxy in front

8. Logging

What to check:

docker info --format '{{.LoggingDriver}}'
cat /etc/docker/daemon.json 2>/dev/null

Guidelines:

  • Django, Gunicorn, and Celery log to stdout and stderr
  • log rotation configured for the json-file driver, or logs shipped to a central system
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "50m", "max-file": "5" }
}

Without rotation, container logs grow until the disk fills.

9. Resources and restarts

services:
  web:
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "2"
          memory: 1g

Guidelines:

  • memory limits sized from measured usage per worker
  • Gunicorn worker counts match the container limits
  • restart policy for crash recovery, plus monitoring of restart counts so crash loops are noticed

Docker Compose applies deploy.resources.limits when running with current Compose versions; verify with docker stats.

10. Release process

Guidelines:

  • images tagged with immutable identifiers (Git SHA or version), not only latest
  • migrations run once per release as a one-off container or job before replacing web containers:
docker compose run --rm web python manage.py migrate --noinput
docker compose up -d --no-deps web
  • rollback documented: deploy the previous tag; migrations kept backward compatible
  • Celery workers and Beat updated with the same image tag as web containers
  • images scanned for vulnerabilities and rebuilt regularly for base image updates

11. Background workers

Guidelines:

  • Celery worker and Beat run as separate services from the same image with different commands
  • exactly one Beat container
  • workers have the same environment variables and secrets as the web service

Verify

docker compose ps
docker compose exec web python manage.py check --deploy
docker inspect --format '{{.State.Health.Status}}' myapp-web-1
docker stats --no-stream

Verification check:

  • containers run as non-root with health status healthy
  • no secrets appear in image history or layers
  • only the proxy publishes ports publicly
  • database and media survive docker compose down and up
  • logs rotate and appear in docker compose logs
  • a deploy of a new tag and a rollback to the previous tag both work

Explanation

Containers package the application consistently, but production qualities (least privilege, secret handling, persistence, observability, and safe releases) still need explicit configuration. Most issues found in Dockerized Django deployments are defaults carried over from development: root users, the development server, secrets in images, missing log rotation, and migrations tied to container startup. Checking each area turns a working container into a production-ready one.

Edge cases / notes

Rootless Docker and Podman

Rootless container engines add another security layer and change UID mappings for bind mounts. Test file permissions for volumes when switching.

Single-host Compose versus orchestrators

Compose on one server is simple and effective for many sites, but a host failure takes everything down. Plan backups and a documented rebuild path, or move to multiple hosts when uptime requirements grow.

Image size versus debuggability

Minimal images without shells are harder to debug. Keep a debug approach ready (for example running a debug container sharing the network and process namespace) before removing shells.

Common mistakes

  • Running runserver in production containers.
  • Baking .env files or secret build arguments into images.
  • Publishing PostgreSQL or Gunicorn ports on all interfaces.
  • Running migrations in the web container's startup with several replicas.

Next steps

FAQ

Should a Django Docker container run as root?

No. Create a dedicated user in the Dockerfile and switch to it with USER. Root inside a container increases the impact of vulnerabilities and container escapes.

How should secrets be passed to Django containers?

At runtime, through environment files with restricted permissions, mounted secret files, or an orchestrator's secret store. Never with ENV or ARG in the Dockerfile.

Where should migrations run in a Docker deployment?

In a one-off container or job using the new image, once per release, before replacing the web containers. Avoid running them in every web container's startup.

Do I need log rotation for Docker containers?

Yes, when using the default json-file logging driver. Configure max-size and max-file, or ship logs to a central logging service.

Learning path · step 12 of 12

Docker, from image to production

Keep going