Skip to main content

Django zero-downtime deployment guide

TL;DR

How to deploy Django updates with zero downtime using safe migrations, Gunicorn graceful reload, and nginx upstream failover.

Key facts

Topology
Django zero-downtime on Linux

TL;DR

Zero-downtime Django deployment means updating code and running migrations without dropping requests or locking tables. This requires backward-compatible migrations, Gunicorn's graceful reload, and a deployment sequence that ensures the old code keeps running until the new code is verified healthy.

Prerequisites

  • Django application running with Gunicorn and nginx (already in production)
  • systemd managing the Gunicorn process
  • A health check endpoint at /health/

Safe database migrations

The most common source of downtime during Django deploys is a migration that locks a table. Follow these rules:

Safe operations (no table locks on modern PostgreSQL):

  • Adding a nullable column
  • Adding an index concurrently
  • Creating a new table
  • Adding a column with a database-level default (Django 4.2+)

Dangerous operations (will lock the table):

  • Renaming a column or table
  • Changing a column type
  • Adding a NOT NULL column without a default
  • Removing a column referenced by running code

For dangerous changes, use a multi-deploy strategy:

Deploy 1: Add new column (nullable) → backfill data
Deploy 2: Update code to use new column → stop writing to old
Deploy 3: Remove old column

Run migrations before deploying new code so the old code continues working against the updated schema:

source /var/www/myapp/venv/bin/activate
cd /var/www/myapp
python manage.py migrate --noinput

For large tables, use AddIndex with concurrently=True (Django 4.2+):

from django.contrib.postgres.operations import AddIndexConcurrently

class Migration(migrations.Migration):
    atomic = False

    operations = [
        AddIndexConcurrently(
            model_name='order',
            index=models.Index(fields=['created_at'], name='idx_order_created'),
        ),
    ]

Gunicorn graceful reload

Gunicorn supports graceful reload via the HUP signal. When the master process receives HUP, it:

  1. Forks new workers with the updated code
  2. New workers begin accepting connections
  3. Old workers finish their current requests
  4. Old workers exit after draining

Send the signal:

sudo systemctl reload gunicorn

This maps to ExecReload=/bin/kill -s HUP $MAINPID in the systemd unit file. Confirm it is set:

[Service]
ExecStart=/var/www/myapp/venv/bin/gunicorn myapp.wsgi:application -c gunicorn.conf.py
ExecReload=/bin/kill -s HUP $MAINPID

nginx upstream health

Configure nginx to handle upstream failures gracefully. If Gunicorn workers are restarting, nginx can retry:

upstream django_backend {
    server unix:/run/gunicorn.sock fail_timeout=10s max_fails=3;
}

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

    location / {
        proxy_pass http://django_backend;
        proxy_next_upstream error timeout http_502 http_503;
        proxy_next_upstream_tries 2;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

proxy_next_upstream tells nginx to retry on the next upstream server (or the same, after recovery) if it gets a 502 or 503 during the reload window.

Deployment script

Tie it all together in a repeatable script:

#!/bin/bash
set -euo pipefail

APP_DIR="/var/www/myapp"
cd "$APP_DIR"
source venv/bin/activate

git fetch origin main
git reset --hard origin/main

pip install -r requirements.txt --quiet

python manage.py migrate --noinput
python manage.py collectstatic --noinput

sudo systemctl reload gunicorn

sleep 5
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/health/)
if [ "$HTTP_CODE" != "200" ]; then
    echo "Health check failed (HTTP $HTTP_CODE) — investigate immediately"
    exit 1
fi

echo "Deploy complete — health check passed"

Pre/post deployment hooks

Run pre-deployment checks before pulling new code:

python manage.py check --deploy
python manage.py showmigrations --plan | grep -c '\[ \]'

Post-deployment, verify the admin and critical API endpoints respond correctly. Log the deployment event with a timestamp and git SHA for audit purposes.

With Reflex

Reflex automates the entire zero-downtime deployment sequence: it runs migrations, executes collectstatic, sends a graceful HUP to Gunicorn, verifies the health endpoint, and rolls back the git checkout if the new version fails. Every step is logged with timestamps and correlated to system metrics, giving you a complete deployment audit trail. See How it works.