Django Maintenance Mode During Deploys
Put a Django site into maintenance mode safely: an Nginx flag file returning 503 with Retry-After, a static maintenance page, staff IP bypass, health checks, and when to avoid maintenance windows.
Problem statement
Some changes cannot be made while users write data: a database major upgrade, a migration that rewrites a huge table, moving to a new server, or restoring from backup. During those minutes, requests fail in confusing ways: 502 errors, half-saved forms, or errors from code that expects the new schema.
A maintenance mode replaces that chaos with a clear, intentional response: users see a friendly page, search engines get 503 Service Unavailable with a Retry-After header (so nothing is deindexed), and staff can still reach the site to verify the work. The key design point is that maintenance mode must work while Django itself is stopped or broken, so it belongs in the proxy, not in the application.
- Create a static
maintenance.htmloutside the release directory. - In Nginx, return
503when a flag file exists, serving that page withRetry-After. - Let staff IP addresses bypass the flag so they can verify the site.
- Enable with
touch /srv/myapp/maintenance.on, disable withrm(no reload needed). - Keep health checks and ACME challenges working during maintenance.
- Use maintenance mode only when a change truly requires stopping writes; prefer backward-compatible, zero-downtime deploys otherwise.
Step-by-step solution
1. Create the maintenance page
<!-- /srv/myapp/maintenance/maintenance.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scheduled maintenance</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 36rem; margin: 15vh auto; padding: 0 1rem; line-height: 1.5; }
</style>
</head>
<body>
<h1>We'll be back shortly</h1>
<p>We're performing scheduled maintenance. Please try again in a few minutes.</p>
</body>
</html>
Keep it self-contained (inline CSS, no Django static files), because static assets from the app may be unavailable or changing during maintenance.
2. Configure Nginx
# http context
geo $maintenance_bypass {
default 0;
203.0.113.0/24 1; # office
198.51.100.7 1; # VPN egress
}
server {
listen 443 ssl;
server_name example.com;
# ssl_certificate lines ...
set $maintenance 0;
if (-f /srv/myapp/maintenance.on) {
set $maintenance 1;
}
if ($maintenance_bypass) {
set $maintenance 0;
}
if ($maintenance) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
root /srv/myapp/maintenance;
rewrite ^ /maintenance.html break;
add_header Retry-After 600 always;
add_header Cache-Control "no-store" always;
}
location /healthz/ {
proxy_pass http://django;
}
location / {
proxy_pass http://django;
# proxy headers ...
}
}
- The flag check happens per request, so creating or deleting the file takes effect immediately without reloading Nginx.
geomatches the client address. Behind a load balancer or CDN, configure therealipmodule first so$remote_addris the real client, or the bypass will not work (or will match everyone).Retry-Aftertells browsers, crawlers, and API clients when to come back. A503withRetry-Afteris the standard signal for temporary unavailability; search engines do not treat it as a removed page if maintenance is short.ifinsideserverwithreturnis one of the safe uses of Nginx'sif; avoid more complex logic insideifblocks.
The /healthz/ location sits outside the maintenance check only if you want the load balancer to keep instances in service during maintenance. If the application is stopped, the health check fails anyway; decide which behaviour your platform needs (some load balancers show their own error page when all targets are unhealthy, which would replace your maintenance page).
sudo nginx -t && sudo systemctl reload nginx
Keep /.well-known/acme-challenge/ on the port 80 server block outside the maintenance check so certificate renewals are not affected.
3. Use it in a deploy
#!/usr/bin/env bash
set -euo pipefail
touch /srv/myapp/maintenance.on
trap 'echo "deploy failed: maintenance mode still ON"' ERR
sudo systemctl stop celery-worker celery-beat
# ... heavy migration, database upgrade, or data move ...
sudo systemctl restart gunicorn
curl -fsS -H "Host: example.com" --unix-socket /run/gunicorn/gunicorn.sock http://localhost/healthz/
# staff verify from a bypass IP, then:
sudo systemctl start celery-worker celery-beat
rm /srv/myapp/maintenance.on
If a step fails, the site stays in maintenance mode rather than serving broken pages. Decide in advance who can turn it off and under what conditions.
Stopping Celery workers during maintenance prevents background tasks from writing to a database that is being migrated.
4. Announce and time it
Tell users in advance (banner, email for business-critical apps), choose low-traffic hours from your analytics, and set Retry-After close to the expected duration. Update the page text if the window is extended.
5. Alternatives
- Load balancer fixed responses: AWS ALB listener rules, Cloudflare, and other edges can return a fixed
503response. Useful when several servers must switch at once. - Application-level maintenance packages (middleware that checks a flag in the database or cache) allow finer control, such as read-only mode, but depend on Django running correctly, which is exactly what may not be true during maintenance.
- Read-only mode: for some sites, keeping reads available while blocking writes is better than a full outage. That needs application support: a flag that disables forms and write endpoints, and database roles or settings that enforce it.
Verification check:
touch /srv/myapp/maintenance.on
curl -sI https://example.com/ | grep -iE "^(HTTP|retry-after)"
curl -s https://example.com/ | grep -o "<h1>.*</h1>"
rm /srv/myapp/maintenance.on
curl -sI https://example.com/ | head -1
From a non-bypass address: 503 with Retry-After while the flag exists, normal responses after removing it. From a bypass address: normal responses throughout.
Explanation
Nginx evaluates the flag file check before proxying, so maintenance responses never touch Gunicorn or Django. That independence is the point: the maintenance page must work when the application is stopped, half-deployed, or failing. The 503 status with Retry-After communicates "temporary" to every kind of client, which protects search rankings and makes API clients retry sensibly.
Edge cases / notes
Most deploys should not need this
Backward-compatible migrations, rolling restarts, and blue-green deployments avoid downtime for routine releases. Reserve maintenance mode for genuinely incompatible changes, and plan those as separate, announced events.
Multiple servers
Every Nginx instance must see the flag. Use shared configuration management to create the file on all hosts, or switch at the load balancer.
When to automate this
Wrap enable, disable, and status in a small script used by both humans and deploy tooling, alert if maintenance mode stays on longer than planned, and include the flag file and bypass list in your Nginx template.
Next steps
- Avoid downtime for routine releases with How to Set Up Zero-Downtime Deployments for Django.
- Reduce the need for maintenance windows with Run Schema Changes on Large Tables in Django Safely.
- Plan the day itself with the Django Go-Live Day Runbook.
- A typical reason for a window is covered in Upgrade PostgreSQL to a New Major Version for Django.
FAQ
How do I put a Django site into maintenance mode?
Have Nginx return 503 with a static page when a flag file exists, and create or remove the file to switch modes.
Why use a 503 status for maintenance?
It tells browsers, crawlers, and API clients the outage is temporary. With Retry-After, they know when to try again, and search engines keep the pages indexed.
Can staff access the site during maintenance?
Yes, if you exempt their IP addresses with an Nginx geo block, after making sure $remote_addr is the real client IP.
Should maintenance mode be implemented in Django middleware?
Not as the primary mechanism. Maintenance often happens while Django is stopped or broken, so the proxy should serve the page.