How do I show a custom error or maintenance page when the backend behind nginx is down?
error_page 502 504 catches a dead backend without proxy_intercept_errors. Tested rules for nginx, HAProxy errorfile, Caddy handle_errors and Traefik errors.
Key points
error_page 502 504 /maint.html;is all nginx needs for a backend that is down. The 502 and 504 are nginx's own responses, soproxy_intercept_errors(defaultoff) plays no part; it only governs responses the backend actually sent, with codes of 300 and above.proxy_intercept_errors onis for the other case: the backend is up and answers 500. Without it that 500 and its body pass straight through, whatevererror_pagesays. With it, every listed code is replaced, the application's own 404s included.- The page keeps the original status.
=200turns an outage into a success that caches and gets indexed as a soft 404; for planned maintenance send503withRetry-After, and in nginx that header needsadd_header ... always. - HAProxy answers an unreachable server with 503, not 502, and
errorfileonly replaces HAProxy's own errors. Caddy'shandle_errorsfires for a dead upstream but not for an upstream's 500. Traefik'serrorsmiddleware catches both.
Put error_page 502 504 /maint.html; in the server block, mark the page's location internal, and nginx serves that page whenever the backend is unreachable or too slow. You do not need proxy_intercept_errors. When the backend is down there is no backend response to intercept: the 502 or 504 is nginx's own, and error_page always handles nginx's own errors. proxy_intercept_errors decides something else, whether a response the backend did send with a code of 300 or above is passed to the client or handed to error_page.
The nginx documentation states the scope in one sentence: the directive "determines whether proxied responses with codes greater than or equal to 300 should be passed to a client or be intercepted and redirected to nginx for processing with the error_page directive." It defaults to off. Every search for this question still turns up mailing list threads from 2009 to 2018 in which someone switches it on to fix a maintenance page. It is harmless for that job and does nothing for it, and it changes how the application's real error responses reach clients. Everything below was tested on nginx 1.27.5 and 1.30.6, HAProxy 3.2.26, Caddy 2.11.7 and Traefik 3.7.15, against a backend that could be stopped, made to close connections, made to stall, or made to answer 500.
Which mechanism catches which failure, in four proxies#
Two different things get called "the backend is down". In one, the proxy could not get a response at all, so the error status is the proxy's own. In the other, the backend answered with an error status. Each proxy handles the two through different mechanisms, and only Traefik treats them alike.
| Backend state | nginx | HAProxy | Caddy | Traefik |
|---|---|---|---|---|
| Process stopped (connection refused) | 502, caught by error_page 502; proxy_intercept_errors irrelevant | 503 after its retries (3 by default, about 3s here), log SC--; caught by errorfile 503 | 502, caught by handle_errors | 502, caught by the errors middleware |
| Every server marked down by active checks | no active checks in open source nginx | 503, <NOSRV>, SC--; errorfile 503 | 503, handle_errors | 503, errors middleware |
| Accepts, then closes without a response | 502, upstream prematurely closed connection; error_page 502 | 502, SH--; errorfile 502 | 502, handle_errors | 502, errors middleware |
| No response header in time | 504, error_page 504 | 504, sH--; errorfile 504 | 504 with response_header_timeout; handle_errors | 504 with responseHeaderTimeout; errors middleware |
| Backend answers 500 (or its own 502 or 503) | passed through with its body, unless proxy_intercept_errors on and a matching error_page | passed through; errorfile never applies; use http-response return | passed through; handle_errors not triggered; use handle_response | caught by the errors middleware if the code is in status |
Read down the last row and the confusion explains itself. In nginx, HAProxy and Caddy the error page feature is for errors the proxy generates, and a backend's own 5xx needs a second mechanism. Traefik's middleware inspects whatever status passes through it, so one configuration covers both cases. What each status means at the proxy, and the log line that confirms it, is in 502 vs 503 vs 504.
nginx: the minimal configuration that works#
server {
listen 80;
server_name example.com;
root /var/www/example;
error_page 502 504 /maint.html;
location = /maint.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8000;
}
}With the backend stopped, GET / returned 502 Bad Gateway with the body of maint.html, and the error log still recorded connect() failed (111: Connection refused) while connecting to upstream. A request that hit proxy_read_timeout returned 504 with the same page. A POST returned the page too, because the error_page URI form makes an internal redirect "with the client request method changed to GET (for all methods other than GET and HEAD)". internal stops anyone requesting /maint.html directly, which would otherwise return it with a 200.
Where to put error_page matters more than any other line. nginx's rule, from the core module documentation: these directives "are inherited from the previous configuration level if and only if there are no error_page directives defined on the current level." A location that defines its own error_page 404 /404.html; therefore loses the server-level 502 line completely. In the test that location returned nginx's built-in "502 Bad Gateway" page while every other location served the maintenance page. If a location needs its own error pages, repeat the 502 and 504 lines in it. The same rule governs add_header and proxy_set_header, which the nginx reverse proxy guide covers at more length.
When proxy_intercept_errors does matter#
Switch it on when the backend is running and you want to replace what it sends. Tested with error_page 500 502 503 504 /maint.html; at server level:
| Backend answered | proxy_intercept_errors off (default) | proxy_intercept_errors on |
|---|---|---|
500 with body APP 500 BODY | 500, the application's body | 500, maint.html |
502 with its own body | 502, the application's body | 502, maint.html |
404 with its own body, no error_page 404 defined | 404, the application's body | 404, the application's body |
404, with error_page 404 defined at server level | 404, the application's body | 404, maint.html |
The second and fourth rows are where it bites. A backend that is itself a proxy, such as an application server behind its own gateway, can send a genuine 502 of its own. Without interception that 502 reaches the client with the inner gateway's page, and the fact that error_page 502 is configured makes no difference. The fourth row is the cost of switching it on: interception covers every code listed in any error_page in scope, so an API's JSON 404 {"error":"no such order"} turned into an HTML maintenance page once a site-wide error_page 404 existed. Turn it on per location, for the locations that serve browsers, and leave API locations alone or give them a JSON error location as the 413 article does for upload limits.
There is also a case that neither setting reaches. An upstream that answers with something that is not HTTP at all is not a 502 in current nginx; it is forwarded as HTTP/0.9 and logged with status 009, so there is no 502 to intercept.
Which status the client receives, and the =200 trap#
The error page is a body. The status stays whatever caused it unless you change it, and the change syntax has two traps. All of these were tested with the backend stopped:
| Configuration | Client received | Why |
|---|---|---|
error_page 502 504 /maint.html; | 502 + page | Status preserved |
error_page 502 =200 /maint.html; | 200 + page | =code replaces the status |
error_page 502 =503 /maint.html; with add_header Retry-After 120; in the page's location | 503, no Retry-After | add_header without always skips 503 |
Same with add_header Retry-After 120 always; | 503 + Retry-After: 120 | always adds it "regardless of the response code" |
error_page 502 = @maint; (named location serving a static file) | 200 + page | Bare = takes the status of whatever the named location returns |
error_page 502 =503 @maint;, client sends POST | 405 Not Allowed | A named location keeps the method; the static file handler refuses POST |
error_page 502 /missing.html; (file absent) | 404 | "the status code of the last occurred error is returned to the client" |
=200 is the line most often copied from forum answers, and it is the one to avoid. A 200 during an outage tells every cache, CDN and monitoring probe that the request succeeded. A CDN that caches 200s can keep serving the maintenance page after the backend is back, an uptime check that tests for 200 stays green through the outage, and Google's documentation says that when "the content suggests an error", Search Console reports a soft 404. The bare = in the named-location form does the same thing by accident, because the named location's static file is a 200.
The 405 row explains why the URI form (error_page 503 /maint.html;) is safer than a named location for a static page. The URI form switches the method to GET; the named location does not, and nginx's static module does not serve files to POST. A form submitted during maintenance then gets "405 Not Allowed" instead of the page. Use a named location only when it ends in proxy_pass or return, which do not care about the method.
add_header applies only when "the response code equals 200, 201, 204, 206, 301, 302, 303, 304, 307, or 308", so on a 503 it is silently dropped unless the line ends in always (nginx 1.7.5 and later).
Why 503 with Retry-After is the right status for maintenance#
RFC 9110 defines 503 as the server being "currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay." It is the only status whose definition names maintenance, and the same section says the server "MAY send a Retry-After header field". Sent with a 503, Retry-After "indicates how long the service is expected to be unavailable to the client", either as seconds (Retry-After: 120) or as an HTTP-date.
502 and 504 describe something different: an invalid response from, or no timely response from, an upstream. For an unplanned outage, leaving the status as 502 or 504 is accurate and keeps your error-rate dashboards honest. For planned maintenance, 503 is the correct description and the one clients are built to treat as temporary. Google's crawlers "temporarily slow down with crawling" on 5xx and 429 responses, while URLs that keep returning server errors are eventually dropped, so keep a 503 window short. A 200 maintenance page gets none of that treatment.
A complete nginx maintenance switch, then, keeps the 502 and 504 behaviour for outages and adds a planned 503:
server {
listen 80;
root /var/www/example;
error_page 502 504 /maint.html;
error_page 503 /maint-503.html;
location = /maint.html { internal; }
location = /maint-503.html {
internal;
add_header Retry-After 600 always;
add_header Cache-Control "no-store" always;
try_files /maint.html =503;
}
location / {
if (-f /etc/nginx/maintenance.on) {
return 503;
}
proxy_pass http://127.0.0.1:8000;
}
}touch /etc/nginx/maintenance.on turns it on without a reload, because the if checks for the file on each request; deleting the file turns it off. In the test, both GET and POST received 503 with Retry-After: 600 and the page. Keep the flag file outside the document root, and keep the page self-contained (inline CSS, no images): any asset it references goes through the same location / and also gets the 503.
HAProxy: errorfile, and why it ignores the backend's 500#
The HAProxy 3.2 manual is direct about errorfile: "It is important to understand that this keyword is not meant to rewrite errors returned by the server, but errors detected and returned by HAProxy. This is why the list of supported errors is limited to a small set." That set, in 3.2, is 200, 400, 401, 403, 404, 405, 407, 408, 410, 413, 414, 425, 429, 431, 500, 501, 502, 503 and 504.
The file is not an HTML page. It is a complete HTTP response, status line and headers included, which is why the manual recommends a .http extension:
HTTP/1.1 503 Service Unavailable
Content-Type: text/html
Retry-After: 120
Cache-Control: no-store
Connection: close
<!doctype html><title>Down for maintenance</title>...defaults
mode http
timeout connect 2s
timeout client 30s
timeout server 30s
errorfile 502 /etc/haproxy/errors/502.http
errorfile 503 /etc/haproxy/errors/503.http
errorfile 504 /etc/haproxy/errors/504.httpThree things in the tests are worth knowing before relying on it:
- A dead server is a 503 in HAProxy. A stopped backend with no
checkproduced503with log flagsSC--after about 3 seconds of retries; withcheck, once the server was marked down,503immediately with<NOSRV>. Only a server that accepted and then closed gave502(SH--), and only a stalled one gave504(sH--). If you copy an nginx habit and define onlyerrorfile 502, the common outage still shows HAProxy's default 503 page. Define all three. The flags are decoded in the HAProxy configuration guide. - The status line in the file is what the client gets. A file starting
HTTP/1.1 200 OKassigned toerrorfile 503sent the client a200, while HAProxy's own log recorded503. The=200trap is easier to fall into here, because it lives in a file nobody reviews. - Files are read once. "The files are read at the same time as the configuration and kept in memory", and "no file change is considered while the process is running". Editing the page needs a reload. The manual also warns the file should stay under the buffer size, "which generally is 16 kB", and should not reference local images, "in order to avoid loops between the client and HAProxy when all servers are down".
For a backend that answers 500, use a response rule, which does see server responses:
backend app
http-response return status 503 content-type text/html file /etc/haproxy/errors/maint.html hdr Retry-After 120 if { status 500:599 }
server a 10.0.0.10:8000Backend 500 and 502 responses both became 503 with Retry-After: 120 and the page, logged with LH-- (L: "the stream was locally processed by HAProxy"). Note the different file type: return ... file takes a body only, while errorfile takes a full response.
Caddy: handle_errors does fire for a dead upstream#
Caddy's documentation warns that "certain directives, for example reverse_proxy which may write a response with an HTTP status which is classified as an error, will not trigger the error routes." That sentence is the source of a widespread belief that handle_errors cannot catch proxy failures. It can. The sentence is about responses the upstream wrote. When Caddy cannot reach the upstream, reverse_proxy returns an error instead of a response, and that error runs the error routes:
:80 {
reverse_proxy 127.0.0.1:8000
handle_errors 502 503 504 {
header Retry-After 120
root * /srv/errors
rewrite * /maint.html
file_server
}
}Stopped upstream: 502 with the page. Upstream that closed without answering: 502. response_header_timeout exceeded: 504. All upstreams down by active health check: 503. A POST got the page too. In each case file_server inside handle_errors kept the error status. An upstream that answered 500, 502 or 503 passed through untouched, exactly as the documentation says.
To replace an upstream's own error responses, use handle_response inside reverse_proxy, and know its two differences from handle_errors:
reverse_proxy 127.0.0.1:8000 {
@bad status 5xx
handle_response @bad {
header Retry-After 120
method GET
root * /srv/errors
rewrite * /maint.html
file_server {
status 503
}
}
}Without status 503, file_server in handle_response answered 200: this route is ordinary request handling, with no error status to inherit. Without method GET, a POST got 405 Method Not Allowed, the same failure as nginx's named location. Both lines were needed for the page to arrive as a 503 for every method. Caddy's other defaults are covered in the Caddy reverse proxy guide.
Traefik: one middleware for both cases#
Traefik's errors middleware "returns a custom page in lieu of the default, according to configured ranges of HTTP Status codes". It needs a service to fetch the page from; there is no file option:
http:
routers:
app:
rule: Host(`example.com`)
service: app
middlewares: [maint-headers, maint-page]
middlewares:
maint-headers:
headers:
customResponseHeaders:
Retry-After: "120"
maint-page:
errors:
status: ["500-599"]
service: error-pages
query: "/{status}.html"
services:
error-pages:
loadBalancer:
servers:
- url: "http://error-pages:80"The middleware caught Traefik's own 502 for a stopped backend and for one that closed the connection, its 504 from responseHeaderTimeout, and its 503 when the health check had removed every server. It also caught the backend's own 500, 502 and 503. The status was preserved each time, and a client POST was fetched from the error service as a GET. statusRewrites: {"500-599": 503} changed the status the client saw to 503, and the headers middleware placed before it added Retry-After to the error response.
The single mechanism has a cost of its own. A range of 500-599 replaces the JSON body of every 5xx your API returns, so scope the middleware to browser routers or narrow status. By default it also forwards every request header to the error page service, "including authentication material such as Authorization and Cookie"; set errorRequestHeaders: [] when that service is anything other than a static file server you control. The router and middleware model is in Traefik routers, services and middlewares.
Failure modes#
- Maintenance page never appears, nginx's default 502 does. A location-level
error_pagereplaced the server-level one. Symptom: the page works on/and not on/api/. Fix: repeat the 502 and 504 lines in that location. - Page appears with a 200, monitoring stays green during the outage.
=200, a bare=with a named location, or a HAProxy error file whose status line says 200. Check withcurl -sIwhile the backend is stopped, not with a browser. Retry-Aftermissing on the 503.add_headerwithoutalwaysin nginx.POSTreturns 405 during maintenance. A named location (nginx) orhandle_responsewithoutmethod GET(Caddy) serving a static file.- The application's 404s and 500s turned into the maintenance page.
proxy_intercept_errors onin a location that serves an API, or a Traefikerrorsmiddleware with a wide range on an API router. - HAProxy shows its own 503 page even though
errorfile 502is set. The backend was unreachable, which in HAProxy is a 503. - The maintenance page is half-styled. It references CSS or images served by the same proxy, which are also failing. Inline everything.
If the outage you are designing for is one server out of several, the better answer is not a page but failover: retrying another backend before any error page is needed. Which proxies notice a dead server, and how fast, is in health checks and upstream failover.
Where this stops applying#
The nginx results are open source nginx 1.27.5 and 1.30.6 with the proxy_pass module. fastcgi_intercept_errors, uwsgi_intercept_errors and grpc_intercept_errors follow the same rule for their own backends, but only proxy_intercept_errors was tested here. HAProxy behaviour is 3.2.26 in mode http with the default retries; option redispatch and multiple servers change how long it takes to reach the 503. Caddy is 2.11.7 and Traefik 3.7.15 with file-provider configuration. A CDN in front of any of these has its own error pages and its own rules about which origin statuses it replaces, so test the full chain from outside.
Frequently asked questions#
Do I need proxy_intercept_errors for a custom 502 page in nginx?#
No. When nginx cannot connect to the backend or times out, the 502 or 504 is generated by nginx and error_page 502 504 /page.html; handles it with proxy_intercept_errors at its default of off. The directive only matters when the backend itself sends a response with status 300 or above that you want to replace.
Why does my nginx error_page work for a stopped backend but not when the app returns 500?#
Because the app's 500 is a proxied response, and proxied responses are passed to the client as they are unless proxy_intercept_errors on is set in that location. Add it, together with error_page 500 ..., only in locations where replacing the application's error body is what you want.
How do I return 503 instead of 502 for the maintenance page?#
Use error_page 502 504 =503 /maint.html;, or return 503; behind a maintenance flag with error_page 503 /maint.html;. Add Retry-After with add_header Retry-After 600 always;, because without always nginx does not add headers to a 503.
Should a maintenance page return 200?#
No. A 200 tells caches, CDNs, uptime checks and crawlers that the request succeeded, so the page can be cached and outages go unrecorded. Use 503 with Retry-After for planned maintenance, which RFC 9110 defines for exactly this case, and leave unplanned failures as 502 or 504.
Why does HAProxy errorfile not replace my backend's 500 page?#
Because errorfile only applies to errors HAProxy generates itself; the manual says it "is not meant to rewrite errors returned by the server". Use http-response return status 503 ... if { status 500:599 } to replace responses from the server.
Does Caddy handle_errors catch a 502 from reverse_proxy?#
Yes, when the upstream is unreachable, closes the connection or times out: those are errors Caddy generates, and they trigger handle_errors with the status preserved. A 5xx response that the upstream actually sent does not trigger it; use handle_response with a status 5xx matcher inside reverse_proxy for that.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- nginx ngx_http_proxy_module (proxy_intercept_errors, proxy_next_upstream)
- nginx ngx_http_core_module (error_page, internal)
- nginx ngx_http_headers_module (add_header always)
- HAProxy 3.2 configuration manual (errorfile, http-error, http-response return)
- Caddy documentation, handle_errors directive
- Traefik Errors middleware (HTTP)
- RFC 9110: HTTP Semantics (503 Service Unavailable, Retry-After)
- Google Search Central, how HTTP status codes and network errors affect Google Search
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.
More in reverse proxy configuration#
- App under /app/ behind a proxy: X-Forwarded-Prefix
- Caddy reverse proxy
- Envoy listeners, routes and clusters
- nginx auth_request vs ForwardAuth: why the 302 fails
- gRPC through a reverse proxy
- HAProxy configuration for HTTP reverse proxying
- ingress-nginx retired: proxy annotation equivalents
- nginx proxy_pass and the trailing slash
- Sticky sessions and session affinity
- Traefik routers, services and middlewares
- WebSockets through a reverse proxy