How to Set Up Zero-Downtime Deployments for Django
A standard Django deploy often causes a brief outage because the old app process stops before the new one is fully ready.
Problem statement
A standard Django deploy often causes a brief outage because the old app process stops before the new one is fully ready. That usually shows up as dropped requests, a burst of 502/503 errors from Nginx, failed admin logins, or users hitting code that expects a database schema that is not ready yet.
In practice, zero downtime Django deployment means:
- existing HTTP traffic is not interrupted during the release
- old workers finish in-flight requests cleanly
- new workers pass readiness checks before receiving traffic
- database changes stay compatible across old and new code during the transition
The hardest part is usually not Gunicorn or Nginx. It is database migration compatibility. If a release removes or renames a column that old code still uses, the deploy is no longer zero-downtime even if the process restart is graceful.
Quick answer
The safest path is:
- keep the old app instances serving traffic
- start the new version in parallel
- verify it with a health check
- switch traffic only after it is ready
- use backward-compatible migrations
- keep a fast rollback path
For a single-host setup, the simplest method is usually Gunicorn behind Nginx with release directories and a controlled port switch. For easier rollback, use blue-green deployment. For container-based stacks, run multiple app instances behind a health-aware reverse proxy or orchestrator and replace versions gradually.
Step-by-step solution
Choose a zero-downtime deployment strategy for Django
1) Gunicorn port switch behind Nginx
Use this when you run Django on one Linux host with systemd, Gunicorn, and Nginx.
Why it works:
- Nginx stays up the whole time
- the new app instance starts before traffic moves
- existing requests can finish on the old instance during cutover
Tradeoffs:
- rollback is slower than blue-green
- migration mistakes still break the release
- single-host capacity limits remain
This is the simplest path for many production Django apps.
2) Blue-green deployment
Use two app environments, for example:
- blue = current production
- green = new release candidate
Nginx routes traffic to one environment at a time. You deploy to the inactive one, verify it, then switch traffic.
Why it works well:
- very fast rollback
- safer testing before cutover
- cleaner separation between versions
Tradeoff: you need enough resources to run both environments at once.
3) Rolling replacement with containers
Use this when you already deploy with Docker or another container-based process and can run more than one web instance.
Pattern:
- run multiple web containers
- start a new container version
- wait for health checks
- remove an old container
- repeat until all are updated
This is a common way to reduce or avoid downtime in container setups when you run multiple app instances behind a health-aware reverse proxy or orchestrator.
Prepare Django for zero-downtime releases
2) Add a health check endpoint
Keep it lightweight. It should confirm the app is ready to serve requests.
# urls.py
from django.http import JsonResponse
from django.urls import path
def health(request):
return JsonResponse({"status": "ok"})
urlpatterns = [
path("health/", health),
]
If you add a database check, keep it cheap and intentional. Do not perform expensive queries or external API calls here.
Verification:
curl -fsS http://127.0.0.1:8001/health/
Expected result: HTTP 200 with {"status":"ok"} or similar.
3) Set Django production and proxy settings correctly
If Django is behind Nginx or Caddy and you terminate HTTPS at the proxy, make sure Django trusts the forwarded scheme and is locked down for production.
# settings.py
DEBUG = False
ALLOWED_HOSTS = ["example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Add this if your deployment needs trusted HTTPS origins for CSRF-protected requests
CSRF_TRUSTED_ORIGINS = ["https://example.com"]
Also keep SECRET_KEY in environment variables or a protected environment file, not inside the release directory.
Why this matters:
ALLOWED_HOSTSprevents host header issuesSECURE_PROXY_SSL_HEADERlets Django detect HTTPS correctly behind the proxy- secure redirects, secure cookies, and CSRF behavior depend on correct HTTPS detection
4) Make database migrations backward-compatible
Use the expand-contract approach:
- first release: add new columns, tables, or indexes while keeping the old schema usable
- deploy code that can read and write both forms if needed
- later release: remove old fields only after all app and worker processes are updated
Avoid in a single zero-downtime release:
- renaming columns without compatibility handling
- dropping columns still used by old workers
- changing nullable to non-null without a safe backfill plan
Only run migrations before cutover if they are backward-compatible with both the old and new application versions.
5) Handle static files safely
Use versioned static file names through Django’s manifest storage.
# settings.py
STORAGES = {
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
},
}
Then collect static files before switching traffic:
source /var/www/app/venv/bin/activate
python manage.py collectstatic --noinput
This avoids old pages referencing assets that disappear after deploy.
6) Keep environment variables consistent
Old and new versions must be able to start with the same production environment unless you are intentionally doing a staged config rollout.
Common rules:
- do not remove required env vars in the same release
- avoid changing secret names and app expectations together
- keep your environment file stable across adjacent releases
Implement zero-downtime deployment with Gunicorn and Nginx
7) Use release directories
Example layout:
/var/www/app/releases/20260424-120000//var/www/app/releases/20260420-103000//var/www/app/current -> /var/www/app/releases/20260420-103000
Create a release:
sudo mkdir -p /var/www/app/releases/20260424-120000
sudo chown -R deploy:www-data /var/www/app
Unpack or sync code into the new release directory, then install dependencies:
cd /var/www/app/releases/20260424-120000
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
Run static collection and only backward-compatible migrations before traffic cutover:
source /var/www/app/releases/20260424-120000/venv/bin/activate
export DJANGO_SETTINGS_MODULE=config.settings.production
python manage.py collectstatic --noinput
python manage.py migrate --noinput
Rollback note: do not move traffic yet. If these steps fail, production traffic is still on the old release.
8) Start the new Gunicorn instance before cutover
A clean single-host method is to run the new release on a new local port, then switch Nginx.
Start a second Gunicorn process:
cd /var/www/app/releases/20260424-120000
source venv/bin/activate
gunicorn config.wsgi:application \
--bind 127.0.0.1:8001 \
--workers 4 \
--timeout 30 \
--graceful-timeout 30 \
--access-logfile - \
--error-logfile -
In production, manage this with systemd rather than a shell session. Example unit:
# /etc/systemd/system/gunicorn-app-green.service
[Unit]
Description=Gunicorn Django app green
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/app/releases/20260424-120000
EnvironmentFile=/etc/app/app.env
ExecStart=/var/www/app/releases/20260424-120000/venv/bin/gunicorn config.wsgi:application \
--bind 127.0.0.1:8001 \
--workers 4 \
--timeout 30 \
--graceful-timeout 30
Restart=on-failure
[Install]
WantedBy=multi-user.target
Start and verify:
sudo systemctl daemon-reload
sudo systemctl start gunicorn-app-green
curl -fsS http://127.0.0.1:8001/health/
9) Reload Nginx without dropping connections
Nginx reloads configuration gracefully. Test before reloading.
Example upstream switch:
upstream django_app {
server 127.0.0.1:8001;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name example.com;
location / {
proxy_pass http://django_app;
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_read_timeout 60s;
}
}
Validate and reload:
sudo nginx -t
sudo systemctl reload nginx
Verification:
curl -I https://example.com/health/
Then repoint the symlink atomically for operator clarity only. In this port-switch pattern, the running Gunicorn process is already using the new release directory directly.
ln -sfn /var/www/app/releases/20260424-120000 /var/www/app/current
After traffic is stable, stop the old Gunicorn service.
Rollback:
- switch Nginx upstream back to the old port or old service
- reload Nginx
- repoint
currentto the previous release if needed
Implement zero-downtime deployment with Docker
10) Use a health check that exists in your image
For docker zero downtime Django deployment, you need more than one app instance and a reverse proxy that can send traffic only to healthy containers.
Example Compose service with a health check that uses Python instead of assuming curl is installed:
services:
web:
image: registry.example.com/myapp:2026-04-24
env_file:
- /etc/app/app.env
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/')"]
interval: 10s
timeout: 3s
retries: 5
11) Scale to more than one app instance first
docker compose up -d --scale web=2
docker compose ps
Then verify your reverse proxy can actually route to multiple healthy app instances. Plain Docker Compose does not provide a full rolling deployment controller, so the exact replacement sequence must be handled explicitly by your proxy and container workflow.
In practice, that usually means one of these:
- Nginx or Caddy in front of multiple app containers on the same Docker network
- an orchestrator that supports rolling updates and readiness checks directly
12) Replace containers gradually
Do not restart every app container at once.
Safer pattern:
- pull the new image
- start one new container
- wait for healthy status
- confirm the proxy is sending traffic to it
- remove one old container
- repeat
If your setup cannot target healthy new containers while old ones still serve traffic, plain Compose alone is not enough for a true zero-downtime rollout.
Rollback is easiest when images are tagged per release. Switch back to the prior image tag and repeat the same replacement process.
Verification after the deploy
13) Check health and smoke tests
Run at least:
curl -fsS https://example.com/health/
Then verify one real path:
- homepage
- authenticated admin login
- a key API endpoint
14) Check migrations, static files, and workers
Confirm:
- new code can read the current schema
- static CSS and JS files load correctly
- Celery or background workers are compatible with the deployed web release
If worker code depends on schema changes, update in a compatible sequence. Do not leave old workers running against incompatible tasks or models.
15) Check logs and error rate immediately
Review:
sudo journalctl -u gunicorn-app-green -n 100 --no-pager
sudo tail -n 100 /var/log/nginx/error.log
Also check Sentry, APM, or metrics if available. A release that starts successfully can still fail under real traffic.
Explanation
This works because traffic is only moved after the new Django version is already running and verified. Nginx reloads gracefully, and the release directory pattern makes rollback predictable. The same principle applies in container environments: keep the old version serving until the new version is confirmed healthy.
Choose by environment:
- Gunicorn + Nginx: best for single-server manual deployments
- blue-green: best when rollback speed matters most
- multi-instance containers: best when you already run a proxy or orchestrator that supports health-based cutover
The main failure mode is still schema compatibility. Zero-downtime process restarts do not protect you from unsafe migrations.
Edge cases / notes
- Long requests: set
--graceful-timeouthigh enough for normal in-flight requests to finish. - WebSockets/ASGI: if you run Django with ASGI and Uvicorn, use the same readiness and phased cutover pattern, but validate connection draining carefully.
- Media files: user uploads should be on shared persistent storage, not inside a release directory.
- TLS: terminate TLS at Nginx or Caddy, pass
X-Forwarded-Proto, and setSECURE_PROXY_SSL_HEADERin Django. - Static file pathing: if Nginx serves static files from disk, make sure your static path is not accidentally tied only to an inactive release.
- Single instance only: if you have exactly one app process and no parallel startup path, true zero-downtime deploys are limited.
- Schema rollback: database rollback is often riskier than code rollback. Prefer forward fixes unless you have tested down migrations.
When manual zero-downtime deployment becomes repetitive
Once you are repeatedly creating release directories, polling health checks, switching upstreams, validating Nginx config, and rolling back failed smoke tests, the process should be scripted. Those steps are good candidates for reusable deploy scripts, systemd unit templates, reverse-proxy templates, and CI jobs. Automating them reduces operator error more than it changes the deployment design itself.
Internal links
For the concepts behind readiness, graceful restarts, and migration safety, see What Zero-Downtime Deployment Means for Django in Production.
If you need the underlying server setup first, follow Deploy Django with Gunicorn and Nginx on Ubuntu.
If you run ASGI instead of WSGI, see Deploy Django ASGI with Uvicorn and Nginx.
For a production hardening review before cutover, use Django Deployment Checklist for Production.
For failure recovery, use How to Roll Back a Django Deployment Safely.
FAQ
Can Django deployments really be zero downtime if I have database migrations?
Yes, but only if migrations are backward-compatible during the transition. Adding columns is usually safe. Dropping or renaming schema used by old code is not safe in the same release.
What is the safest zero-downtime strategy for a single-server Django app?
Usually Gunicorn behind Nginx with parallel startup, health checks, and a controlled Nginx switch. If the host has enough resources, a blue-green layout on the same server gives easier rollback.
How do I deploy Gunicorn without dropping active requests?
Start the new app instance first, wait for readiness, then switch traffic. Keep the old instance running long enough for in-flight requests to finish before stopping it.
How do I roll back if the new release passes startup but fails after cutover?
Switch traffic back to the previous app instance or upstream immediately, then investigate. Keep the previous release directory and image tag available so rollback is fast and does not require rebuilding.
Do I need Docker or Kubernetes for zero-downtime Django deployment?
No. Docker helps when you run multiple app instances behind a suitable proxy or orchestrator, but a Linux host with systemd, Gunicorn, and Nginx can handle zero-downtime Django deployment if you run old and new versions in parallel and keep migrations compatible.