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.
| Layer | Error Codes | Tool |
|---|---|---|
| Application framework | 404, 500, 403 | Django, Rails, Express |
| Reverse proxy | 502, 503, 504 | Nginx, HAProxy |
| CDN edge | 520, 521, 522, 530 | Cloudflare, 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:
| Code | Meaning |
|---|---|
| 520 | Origin returned an unexpected response |
| 521 | Origin refused connection |
| 522 | Connection timed out |
| 523 | Origin unreachable |
| 524 | A 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.