Use Caddy as a reverse proxy with custom error pages
May 2, 2026 · View on GitHub
This guide shows how to run error-pages as a live sidecar service, so Caddy can replace 4xx/5xx responses from any upstream with styled error pages - without rebuilding images.
Note
Unlike the Caddy image guide, which embeds pre-rendered HTML into a custom Docker image, this approach runs error-pages as a separate container. The trade-off is slightly more moving parts, but you can update templates at runtime without touching Caddy at all.
We need two files: a Caddyfile and a compose.yml.
The Caddyfile:
# File: Caddyfile
# http:// is required: without an explicit scheme, Caddy defaults to port 443 regardless of the
# auto_https setting localtest.me resolves to 127.0.0.1 via public DNS - no /etc/hosts entry needed
http://test.localtest.me {
# triggers for errors generated by Caddy itself (e.g. upstream unreachable → 502); does NOT
# trigger for 4xx/5xx returned by the upstream - those are handled below
handle_errors {
# rewrite without an extension so error-pages uses the client headers to pick the
# response format
rewrite * /{err.status_code}
reverse_proxy error-pages:8080
}
reverse_proxy nginx:80 {
@upstream_error status 4xx 5xx
handle_response @upstream_error {
# rewrite uses {http.reverse_proxy.status_code} as a runtime placeholder; the `error`
# directive cannot be used here - it evaluates the status code at config load time and
# ignores placeholders, always falling back to 500.
# no extension - error-pages negotiates the format via headers from the client request
rewrite * /{http.reverse_proxy.status_code}
reverse_proxy error-pages: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:
caddy:
image: docker.io/library/caddy:2.11-alpine
ports: ['80:80/tcp']
volumes: [./Caddyfile:/etc/caddy/Caddyfile:ro]
depends_on:
# do not start Caddy until error-pages is up and passes 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:
# choose which built-in HTML template to use for error pages
TEMPLATE_NAME: l7
nginx:
# any upstream service - replace this with your own application image
image: docker.io/library/nginx:1.29-alpine
Place both files in the same directory, then run:
docker compose up
Now you can verify that everything works as expected:
# nginx serves its default page normally - no error-pages involved
curl -s http://test.localtest.me/ | head -n 12
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
# nginx returns 404; Caddy intercepts it and serves a styled error page instead
curl -s -H "Accept: text/html" http://test.localtest.me/nonexistent | 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/nonexistent
{
"error": true,
"code": 404,
"message": "Not Found",
"description": "The server can not find the requested page"
}
The same mechanism applies to any status in the 4xx–5xx range. For example, stopping the nginx container
while Caddy is running will cause Caddy to return a 502 Bad Gateway, which is also intercepted and replaced with
a styled error page:
# stop nginx to trigger a 502 Bad Gateway error in Caddy
docker stop $(docker ps -aq --filter "name=nginx")
curl -s http://test.localtest.me/nonexistent | head -n 15
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="robots" content="nofollow,noarchive,noindex">
<title>Bad Gateway</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- -->
<meta http-equiv="refresh" content="30">
<!-- -->
<meta name="title" content="502: Bad Gateway">
<meta name="description" content="The server received an invalid response from the upstream server">
<meta property="og:title" content="502: Bad Gateway">
<meta property="og:description" content="The server received an invalid response from the upstream server">
<meta property="twitter:title" content="502: Bad Gateway">