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.

Maintenance switch in NginxFlag filetouch to enableNginx checkper requestVisitors503 + Retry-AfterStaff IPspass to DjangoThe switch lives in the proxy, so it works even while Gunicorn is stopped or migrating.
  1. Create a static maintenance.html outside the release directory.
  2. In Nginx, return 503 when a flag file exists, serving that page with Retry-After.
  3. Let staff IP addresses bypass the flag so they can verify the site.
  4. Enable with touch /srv/myapp/maintenance.on, disable with rm (no reload needed).
  5. Keep health checks and ACME challenges working during maintenance.
  6. 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.
  • geo matches the client address. Behind a load balancer or CDN, configure the realip module first so $remote_addr is the real client, or the bypass will not work (or will match everyone).
  • Retry-After tells browsers, crawlers, and API clients when to come back. A 503 with Retry-After is the standard signal for temporary unavailability; search engines do not treat it as a removed page if maintenance is short.
  • if inside server with return is one of the safe uses of Nginx's if; avoid more complex logic inside if blocks.
a maintenance switch in Nginxif (-f /srv/myapp/maintenance.on) { set $maintenance 1; }if ($maintenance_bypass) { set $maintenance 0; }if ($maintenance) { return503; }add_header Retry-After 600 always;location/healthz/{ proxy_pass http://django; }12341Touch or delete a file; no reload needed.2Office and VPN addresses still reach Django.3Search engines treat 503 as temporary.4Health checks keep passing during maintenance.Behind a CDN, set up realip first or the bypass matches the CDNdjango-deployment.com
Never return 200 for a maintenance page, or it may be indexed in place of the site.

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
maintenance mode inside a deploytouch maintenance.onvisitors see 503 pageDeploy codestaff test via bypassRun migrationsno writes arrivingSmoke testfrom a bypass IPrm maintenance.ontraffic returnsThe window is only as long as the slowest stepdjango-deployment.com
Use a trap in the deploy script so the flag is removed even if a step fails.

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 503 response. 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.
do you need downtime at all?DowntimeUse whenGraceful reload✓nonecompatible migrationsRolling or blue-green✓noneseveral instancesRead-only mode!writes onlylong data migrationsMaintenance page✕fullincompatible changesMost deploys fit the first rowdjango-deployment.com
Plan migrations to be compatible and maintenance mode becomes rare.

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

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.

Keep going