Zero-Downtime Deploys

Users should never see an error page during deployment. Here's how to achieve seamless updates.

The Problem

A naive deploy looks like:

git pull
pip install -r requirements.txt
systemctl restart gunicorn  # ← Users see errors here

During the restart, requests fail. With enough traffic, this is unacceptable.

Strategy 1: Gunicorn Graceful Reload

Gunicorn can reload workers without dropping connections:

# Instead of restart (drops connections)
sudo systemctl restart gunicorn

# Use reload (graceful worker replacement)
sudo systemctl reload gunicorn

# Or send HUP signal directly
kill -HUP $(cat /run/gunicorn/pid)

How it works: 1. Master process spawns new workers with updated code 2. Old workers finish their current requests 3. Old workers shut down 4. No requests dropped

Strategy 2: Rolling Restart with Nginx

Use Nginx as a reverse proxy with upstream health checks:

upstream django {
    server 127.0.0.1:8000;
    server 127.0.0.1:8001 backup;
}

server {
    location / {
        proxy_pass http://django;
        proxy_next_upstream error timeout;
    }
}

Deploy process: 1. Start new version on port 8001 2. Health check passes → add to upstream 3. Drain connections from old version 4. Stop old version

Strategy 3: Blue-Green Deployment

Maintain two identical environments:

            ┌─────────┐
            │  Nginx   │
            └────┬─────┘
           ┌─────┴─────┐
      ┌────▼───┐  ┌────▼───┐
      │  Blue  │  │ Green  │
      │ (live) │  │ (idle) │
      └────────┘  └────────┘
  1. Deploy new code to the idle environment
  2. Run migrations and tests
  3. Switch Nginx to point at the new environment
  4. Old environment becomes idle

Database Migrations

The trickiest part. Rules for zero-downtime migrations:

  • Adding columns/tables is safe (old code ignores new columns)
  • Removing columns requires two deploys: stop using → then remove
  • Renaming columns: add new → copy data → update code → drop old
  • Always use --fake carefully and test migrations on staging first
# Safe: adding a nullable column
class Migration(migrations.Migration):
    operations = [
        migrations.AddField(
            model_name="post",
            name="subtitle",
            field=models.CharField(max_length=255, null=True, blank=True),
        ),
    ]