Caddy’s default error pages are functional but bland; replacing them with custom, branded, and more informative pages is a common requirement.

Let’s see how Caddy handles an error, specifically a 404 Not Found, when it’s configured to serve custom error pages.

Consider this Caddyfile configuration:

:80 {
    root * /var/www/html
    file_server
    handle_errors {
        rewrite * /error.html
        file_server
    }
}

And here’s a simple error.html file in /var/www/html:

<!DOCTYPE html>
<html>
<head>
    <title>Oops! Something went wrong.</title>
    <style>
        body { font-family: sans-serif; text-align: center; padding: 50px; }
        h1 { color: #cc0000; }
    </style>
</head>
<body>
    <h1>We're sorry, but we couldn't find what you were looking for.</h1>
    <p>Error Code: {err.status}</p>
    <p>Please check the URL or go back to the <a href="/">homepage</a>.</p>
</body>
</html>

Now, if a user requests a non-existent file, say /nonexistent.txt, Caddy will execute the handle_errors block. The rewrite * /error.html directive will internally change the request path to /error.html. Then, file_server will serve the error.html file. The {err.status} placeholder will be dynamically replaced with the original error code, 404. The output to the user will be our custom HTML page with "Error Code: 404" displayed.

The fundamental problem Caddy’s handle_errors addresses is how to provide a user-friendly experience when upstream services or file lookups fail, rather than showing the default, often technical, server error. It allows you to intercept any HTTP error response generated by a preceding directive (like file_server, reverse_proxy, etc.) and take specific actions.

Internally, Caddy maintains a list of directives that can trigger errors. When an error occurs, Caddy checks if a handle_errors block is present in the current scope. If it is, Caddy rewrites the request to the path specified within handle_errors (or executes other directives there) and attempts to serve content. This rewrite is crucial: it’s not a redirect; the user’s browser doesn’t see a new URL, and the HTTP status code of the original error is preserved by default for the client, even though Caddy is serving a different file. The {err.status} placeholder is a special Caddy variable that exposes the original status code of the error that triggered the handle_errors block.

The handle_errors directive is a block, meaning it can contain multiple directives. These directives are executed sequentially only if an error has occurred. If no error occurs, the handle_errors block is skipped entirely. This is why you can have a rewrite inside it to point to your custom error page, followed by another file_server to actually serve that page.

The scope of handle_errors matters. If placed at the top level of your site, it catches errors from any directive in that site. If placed within a specific route or handle block, it only catches errors originating from directives within that specific block. This allows for granular error handling – perhaps a 404 from the main site handler should show one page, but a 404 from a specific API route should show another.

A common point of confusion is how to serve a different status code with your custom error page. By default, handle_errors preserves the original error status code. If you want to return a 200 OK with your custom error page (perhaps for SEO reasons, though generally not recommended for actual errors), you’d need to explicitly set the status code before serving the file. This is achieved by adding a status directive within the handle_errors block, before the file_server or other content-serving directive. For example:

handle_errors {
    # This would return a 200 OK status code to the client
    # even though an error originally occurred.
    status 200
    rewrite * /error.html
    file_server
}

However, be cautious: returning a 200 OK for a 404 or 500 error can be misleading for clients and search engines. The primary purpose of HTTP error codes is to communicate the nature of the problem.

The handle_errors directive is powerful, but it’s important to remember it’s a mechanism for handling errors, not necessarily fixing them. If your backend service is down, handle_errors can show a nice "service unavailable" page, but it won’t bring the service back online. The {err.message} placeholder can also be useful for debugging, providing a human-readable description of the error that occurred, though it’s generally not recommended to expose this directly to end-users for security reasons.

Once you’ve mastered custom error pages, you’ll likely want to explore how Caddy can automatically redirect users from old URLs to new ones using the redir directive.

Want structured learning?

Take the full Caddy course →