Production Infrastructure

Custom Error Pages: 404, 500, 502, and Maintenance Pages

How to design and serve custom error pages at every layer — application error templates, Nginx error_page directive, CDN custom pages, and maintenance mode with 503 Service Unavailable and Retry-After.

Error Page Layers

A user hitting a broken URL passes through multiple layers before seeing an error page. Each layer can generate its own error — and each layer must be configured to show a branded, helpful response rather than a raw server error.

LayerError CodesTool
Application framework404, 500, 403Django, Rails, Express
Reverse proxy502, 503, 504Nginx, HAProxy
CDN edge520, 521, 522, 530Cloudflare, CloudFront
Browser(when server unreachable)Chrome, Firefox

A common mistake is configuring only the application layer, then shipping Nginx's default grey error pages to users when the app is down (502) or overloaded (503).

Application Error Pages

Django

Django renders error pages from templates. Create these files in your templates root:

templates/
├── 404.html
├── 500.html
├── 403.html
└── 400.html
# settings.py
DEBUG = False  # Error templates only render when DEBUG=False
TEMPLATES = [{
    'DIRS': [BASE_DIR / 'templates'],  # Must be in search path
}]

Django uses these templates automatically — no URL configuration needed. The context is empty by default (no request object for security reasons), so error templates must be self-contained with inline styles or absolute CDN URLs.

<!-- templates/404.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Page Not Found — YourApp</title>
  <style>
    /* Inline all critical CSS — no local static files */
    body { font-family: system-ui; max-width: 600px; margin: 4rem auto; }
    h1 { font-size: 3rem; color: #111; }
  </style>
</head>
<body>
  <h1>404</h1>
  <p>We could not find that page.</p>
  <a href="/">Go to homepage</a>
</body>
</html>

For more context in error pages, use a custom 404 handler:

# apps/core/views.py
from django.shortcuts import render

def page_not_found(request, exception):
    return render(request, '404.html', status=404)

def server_error(request):
    return render(request, '500.html', status=500)

# urls.py (root)
handler404 = 'apps.core.views.page_not_found'
handler500 = 'apps.core.views.server_error'

Express (Node.js)

// Error handling middleware (must have 4 parameters)
app.use((err, req, res, next) => {
  const status = err.status || 500;
  res.status(status).render(`errors/${status}`, {
    message: status === 500 ? 'Internal server error' : err.message,
  });
});

// 404 catch-all (must be last route)
app.use((req, res) => {
  res.status(404).render('errors/404');
});

Reverse Proxy Error Pages

Nginx can intercept upstream errors and serve its own error pages — critical for displaying branded 502/503/504 pages when the app is down.

server {
    # Store error pages in a dedicated directory
    root /var/www/yourapp/errors;

    # Intercept upstream errors
    proxy_intercept_errors on;

    error_page 404              /404.html;
    error_page 500 502 503 504  /50x.html;

    # Serve error pages directly (bypass proxy)
    location = /404.html {
        internal;  # Only accessible via error_page directive
    }

    location = /50x.html {
        internal;
    }
}
# HAProxy equivalent
defaults
    errorfile 400 /etc/haproxy/errors/400.http
    errorfile 502 /etc/haproxy/errors/502.http
    errorfile 503 /etc/haproxy/errors/503.http
    errorfile 504 /etc/haproxy/errors/504.http

HAProxy error files must be full HTTP responses including headers:

HTTP/1.0 503 Service Unavailable\r
Cache-Control: no-cache\r
Content-Type: text/html\r
\r
<html><body><h1>Service temporarily unavailable</h1></body></html>

CDN Error Pages

Cloudflare Custom Pages

Cloudflare shows its own error pages when the origin is unreachable (52x errors). You can customize these in the Cloudflare dashboard:

  • Go to Account Home → Custom Pages
  • Select the error type (5xx, 1xxx, etc.)
  • Upload an HTML page (maximum 1.5MB, no external resources)

Cloudflare 52x errors are distinct from origin 5xx errors:

CodeMeaning
520Origin returned an unexpected response
521Origin refused connection
522Connection timed out
523Origin unreachable
524A timeout occurred

CloudFront Custom Error Responses

resource "aws_cloudfront_distribution" "app" {
  custom_error_response {
    error_code            = 502
    response_code         = 503
    response_page_path    = "/errors/maintenance.html"
    error_caching_min_ttl = 30
  }

  custom_error_response {
    error_code            = 404
    response_code         = 404
    response_page_path    = "/errors/404.html"
    error_caching_min_ttl = 300
  }
}

Maintenance Mode (503)

Planned maintenance should return 503 Service Unavailable with a Retry-After header. This tells clients and search engines when to retry.

Nginx Maintenance Mode

# Create a maintenance flag file
# touch /var/www/yourapp/maintenance.flag

server {
    # Check for maintenance flag
    if (-f /var/www/yourapp/maintenance.flag) {
        return 503;
    }

    error_page 503 @maintenance;

    location @maintenance {
        root /var/www/yourapp/errors;
        rewrite ^(.*)$ /maintenance.html break;
        add_header Retry-After 3600;    # Try again in 1 hour
        add_header Cache-Control 'no-store';
    }
}

Enable maintenance: touch /var/www/yourapp/maintenance.flag

Disable maintenance: rm /var/www/yourapp/maintenance.flag

Health Check Bypass

Load balancer health checks must not be affected by maintenance mode, or the LB will deregister the instance:

server {
    location /health/ {
        # Bypass maintenance check for health checks
        return 200 'OK';
        add_header Content-Type text/plain;
    }

    location / {
        if (-f /var/www/yourapp/maintenance.flag) {
            return 503;
        }
        proxy_pass http://app_servers;
    }
}

Googlebot and Search Engines

When Googlebot receives a 503 with Retry-After, it respects the maintenance window and does not deindex the page. Without Retry-After, extended 503 periods can cause ranking drops.

HTTP/1.1 503 Service Unavailable
Retry-After: 3600
Content-Type: text/html

Keep maintenance windows under 2 hours where possible. If downtime exceeds 24 hours, 503 without Retry-After may cause crawlers to start treating the content as removed.

Giao thức liên quan

Thuật ngữ liên quan

Thêm trong Production Infrastructure