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
DEBUGoff,ALLOWED_HOSTSexplicit, secrets from the environment.manage.py check --deployclean, 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.