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:
- Forks new workers with the updated code
- New workers begin accepting connections
- Old workers finish their current requests
- 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.