Use Nginx as a reverse proxy with custom error pages
May 2, 2026 ยท View on GitHub
This guide shows how to use Nginx as a reverse proxy where any 4xx/5xx response from an upstream application is replaced with a styled error page served by a live error-pages sidecar.
Note
This approach requires no image rebuilds - the error-pages container runs alongside Nginx and serves pages on demand. For a static alternative that bakes pages into a custom image, see the Nginx image guide.
We need two files: an nginx.conf and a compose.yml.
The Nginx configuration:
# File: nginx.conf
server {
listen 80;
server_name test.localtest.me; # localtest.me resolves to 127.0.0.1 via public DNS
# without this, Nginx forwards upstream 4xx/5xx responses to the client as-is;
# this directive makes Nginx intercept them and apply the error_page rules below
proxy_intercept_errors on;
# nginx has no range syntax for error_page - codes must be listed individually
error_page 400 401 403 404 405 408 409 410 429 500 502 503 504 /_error-proxy;
location = /_error-proxy {
internal; # not reachable from outside; only triggered by error_page above
proxy_set_header X-Code $status; # $status holds the intercepted upstream code
proxy_pass http://error-pages:8080;
}
location / {
proxy_pass http://httpbin:8080;
}
}
The Compose file:
# yaml-language-server: $schema=https://cdn.jsdelivr.net/gh/compose-spec/compose-spec@master/schema/compose-spec.json
# file: compose.yml
services:
nginx:
image: docker.io/library/nginx:1.29-alpine
ports:
- "80:80/tcp"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
# do not start Nginx until error-pages is up and passing its health check,
# ensuring the error handler backend is ready before any traffic arrives
error-pages: {condition: service_healthy}
error-pages:
image: ghcr.io/tarampampam/error-pages:4
environment:
TEMPLATE_NAME: l7
httpbin:
# go-httpbin: /status/{code} returns the requested HTTP status code - useful for testing
image: ghcr.io/mccutchen/go-httpbin:2.22
Place both files in the same directory, then run:
docker compose up
Now you can verify that everything works as expected:
# httpbin responds normally - error-pages is not involved
curl -s http://test.localtest.me/get
{
"args": {},
"headers": {
"Accept": [
"*/*"
],
"Host": [
"httpbin:8080"
],
"User-Agent": [
"curl/8.11.1"
]
},
"method": "GET",
"origin": "172.20.0.4",
"url": "http://httpbin:8080/get"
}
# httpbin returns 404; Nginx intercepts it and serves the styled error page instead
curl -s -H "Accept: text/html" http://test.localtest.me/status/404 | head -n 15
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="robots" content="nofollow,noarchive,noindex">
<title>Not Found</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- -->
<meta name="title" content="404: Not Found">
<meta name="description" content="The server can not find the requested page">
<meta property="og:title" content="404: Not Found">
<meta property="og:description" content="The server can not find the requested page">
<meta property="twitter:title" content="404: Not Found">
<meta property="twitter:description" content="The server can not find the requested page">
<style>
# the same response, but in JSON format - the error page template can also generate JSON
curl -s -H "Accept: application/json" http://test.localtest.me/status/404
{
"error": true,
"code": 404,
"message": "Not Found",
"description": "The server can not find the requested page"
}
The same applies to any other code listed in error_page - try /status/503, /status/429, etc. For the
upstream-unreachable case, Nginx will generate a 502 Bad Gateway, which is also intercepted and styled:
# stop httpbin to trigger a 502 Bad Gateway error in Nginx
docker stop $(docker ps -aq --filter "name=httpbin")
curl -s -H "Accept: application/json" http://test.localtest.me/status/404 | head -n 15
{
"error": true,
"code": 502,
"message": "Bad Gateway",
"description": "The server received an invalid response from the upstream server"
}