Episode 17 built the CI/CD pipeline: push code to main, tests run, then the deploy goes to the server automatically. But an automatic deploy isn’t necessarily a zero-downtime one — restart the app while a user is entering an expense and their request fails with a 502. This episode finishes the last step: shipping the new version without users noticing, and rolling back in seconds.
Why Stop-and-Restart Deploys Cause Downtime
The simplest deploy is: stop the service, pull the new code, run migrations, then start again. In the gap between stopping and the new app being ready, every request fails — with Gunicorn, usually 5–30 seconds. A zero-downtime deploy removes that gap by always keeping at least one version of the app running.
The Golden Rule: Migrations First, Code Second
The correct order for every deploy is: pull the new code, run migrations, then restart the app. Migrations must be backward compatible — old code still runs on the new schema — following the episode 6 pattern: add a nullable column first, deploy the new code, then clean up the old column next time. Do it the other way around and the new app will crash on the old schema immediately.
#!/bin/bash set -e cd ~/expense-app git pull origin main python manage.py migrate --noinput python manage.py collectstatic --noinput sudo systemctl reload gunicorn curl -fsS http://localhost:8000/health/ Rolling Deploys with Gunicorn and systemd
A rolling deploy is the simplest approach: replace workers one at a time instead of stopping the whole app. Gunicorn supports graceful reload — send the HUP signal, the master process starts new workers running the new code, while old workers finish their in-flight requests before shutting down. The systemd config below uses exactly this mechanism, so a deploy is just one reload command — good enough for a single-server expense tracker.
[Service] ExecStart=/home/linh/expense-app/venv/bin/gunicorn expense.wsgi:application --bind 127.0.0.1:8000 --workers 3 ExecReload=/bin/kill -s HUP $MAINPID KillMode=mixed TimeoutStopSec=30 Blue-Green Deploys with Nginx
When you need more certainty — for example a big change, or an instant rollback — use blue-green: run two versions of the app side by side on two ports, with Nginx pointing only at the live one. Deploy the new version to the green port, verify it, then switch the upstream to green; the old version keeps running untouched, so rollback is just switching the upstream back. It costs double the resources, but in return you get a rewind button that works in seconds.
upstream expense_app { server 127.0.0.1:8001; # blue: ban dang chay # server 127.0.0.1:8002; # green: ban moi, bat len khi deploy } server { listen 80; server_name chi-tieu.example.com; location / { proxy_pass http://expense_app; proxy_set_header Host $host; } } Health Checks and Rollback
Rolling or blue-green, never send traffic to the new version before confirming it’s alive. After the reload or upstream switch, call the /health/ endpoint from episode 8 and wait until it returns 200. If the health check fails, stop and keep the old version.
A fast rollback is the other half of a zero-downtime deploy. With rolling, keeping the old code and reloading back is enough; with blue-green, just switch the Nginx upstream back to the old version and reload Nginx. Note: if a migration already dropped a column the old code still uses, the rollback will fail — so always drop columns in a separate deploy, after the new code has been running fine.
Apply It to Your Project
For the expense tracker, start with a rolling deploy: add ExecReload to the Gunicorn service, then change the episode 17 deploy script from restart to reload — one line of change and the downtime is gone. As users grow, move to blue-green with ports 8001 and 8002, and write the process into a runbook.
Related Posts
- Django in Production #17: CI/CD with GitHub Actions — Automated Tests, Then Deploy
- Django Production Operations #6: Safe Migrations in Production — Backward-Compatible Patterns, Zero-Downtime Deploys
- Django in Production #16: Docker for Production — Multi-Stage Builds, Non-Root Runtime, Healthchecks
Frequently Asked Questions
How is a rolling deploy different from blue-green?
Rolling replaces workers one at a time on the same app — simple and enough for one server. Blue-green runs two complete versions side by side and switches traffic with Nginx — instant rollback, but it costs double the resources during the switch.
When should migrations run for zero downtime?
Before the restart or reload, and migrations must be backward compatible: old code still runs on the new schema. Add a nullable column this time, clean up the old column next time.
What if the health check fails?
Stop the deploy immediately and keep the old version. With rolling, the old workers are still serving, so users are unaffected; with blue-green, just don’t switch the upstream. Then read the new version’s logs, fix the bug, and deploy again.
References
- Gunicorn — docs.gunicorn.org
- Django deployment checklist — docs.djangoproject.com
- Nginx ngx_http_upstream_module — nginx.org
Originally published at linhfish.dev.
