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 4xx5xx 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">