Skip to content
Home About Services Work Notes Contact Start a project
DevOps

A Django Deployment You Can Reason About at 2am

The measure of a deployment is not how elegant it is. It is how quickly someone who did not build it can put the previous version back.

DockerNginxGunicornDeployment

The measure of a deployment is not how elegant it is. It is how quickly someone who did not build it can put the previous version back at two in the morning.

Everything below is chosen with that in mind: boring, inspectable, and reversible in about a minute.

The shape

internet -> nginx  -> gunicorn (unix socket) -> django
             |            |
             |            +-> postgres
             |            +-> redis  <- celery workers
             |
             +-> /static/  from disk
             +-> /media/   from disk

The important line is the last two. Nginx serves static and media directly. Django never should — and if it currently does, that is almost always because DEBUG is on, which is a much bigger problem than the file serving.

Sizing Gunicorn

The (2 × cores) + 1 formula is a starting point for CPU-bound sync work. Django requests are mostly waiting on the database, so threads buy you more than processes on a small box:

gunicorn config.wsgi:application \
  --workers 3 \
  --threads 4 \
  --worker-class gthread \
  --bind unix:/run/app.sock \
  --timeout 60 \
  --graceful-timeout 30 \
  # restart workers periodically: a slow leak in a dependency
  # becomes invisible instead of a 3am OOM
  --max-requests 1000 \
  --max-requests-jitter 100 \
  --access-logfile - --error-logfile -

Remember that every worker × thread holds a database connection. Three workers with four threads is twelve connections from this process alone — see connection pooling before scaling this up.

Log to stdout and let the process manager handle files. Applications that manage their own log rotation eventually fail at it.

A Dockerfile that is not 1.2GB

FROM python:3.12-slim AS build
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
# runtime needs libpq, not the compiler that built against it
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
    && rm -rf /var/lib/apt/lists/*
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir /wheels/*

# never run as root
RUN useradd -m -u 1000 app
COPY --chown=app:app . .
USER app

RUN python manage.py collectstatic --noinput
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]

Copy requirements.txt before the source. Docker caches layers, and source changes on every commit while dependencies change monthly — get this order wrong and every build reinstalls the world.

Migrations do not belong in the container start command

A CMD of migrate && gunicorn means three replicas run migrations simultaneously on startup. Django takes a lock so they will not corrupt anything, but two of them sit waiting, and a failed migration turns into a crash loop that looks like an application bug.

Run migrations as an explicit step that either succeeds or stops the deploy:

docker compose run --rm web python manage.py migrate --noinput
docker compose up -d --no-deps web

The corollary is that migrations must be backwards-compatible with the running code, because for a moment both versions are live. Add a column, deploy code that writes it, then deploy code that requires it — three steps, not one.

Health checks that mean something

A health check returning 200 OK from a view that touches nothing tells you the Python process is alive, which is rarely the question.

def healthz(request):
    # liveness: is this process able to serve at all
    return HttpResponse("ok")

def readyz(request):
    # readiness: can it actually do work
    try:
        connection.ensure_connection()
        cache.set("readyz", "1", timeout=5)
    except Exception as exc:
        return HttpResponse(f"not ready: {exc}", status=503)
    return HttpResponse("ready")

Keep them separate. A database blip should take the instance out of the load balancer, not restart the container — restarting does not fix a database.

Nginx

server {
    listen 443 ssl http2;
    server_name example.com;

    gzip_vary on;
    gzip_min_length 256;
    gzip_types text/plain text/css application/javascript application/json
               application/xml image/svg+xml;

    location /static/ {
        alias /srv/app/static/;
        access_log off;
        # only safe with hashed filenames from ManifestStaticFilesStorage
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location /media/ { alias /srv/app/media/; access_log off; }

    location / {
        proxy_pass http://unix:/run/app.sock;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

That expires 30d is a trap without content hashing. Deploy new CSS under the same filename and returning visitors keep the old one for a month. Either use ManifestStaticFilesStorage so filenames change with content, or keep the TTL short.

Since nginx terminates TLS, Django needs to be told:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

Without it request.is_secure() is False behind the proxy, which quietly breaks secure-cookie logic and any URL you build from the request.

Rollback

Tag images with the commit SHA, never only latest. Rollback is then repointing a tag and restarting:

docker tag registry/app:9f2c1ab registry/app:current
docker compose up -d --no-deps web

The database is what makes this non-trivial: a rollback cannot unapply a destructive migration. Which is the real argument for additive migrations — not elegance, but the ability to go backwards at 2am without losing a column.

The five-minute checklist

  • DEBUG off, ALLOWED_HOSTS explicit, secrets from the environment.
  • manage.py check --deploy clean, or every warning consciously accepted.
  • Automated backups, and a restore you have actually performed.
  • Logs going somewhere searchable.
  • An alert that fires on 5xx rate, reaching a human.

None of it is clever. All of it is the difference between an incident and an outage.

Next step

Need this built properly?

Tell me what you are trying to ship. You get an approach, a timeline and a realistic estimate — usually within a working day.