For static pages like this blog I like to use Caddy as a webserver. It is (mostly) easy to configure, small, fast and easy to put into a Docker container. Recently I wanted to serve a custom 404 page and assumed it would be straight forward with Caddy. At least for me it was not.
handle_errors
with rewrite
The documentation contains a few examples how to deal with errors, which all come down to something like this:
handle_errors {
rewrite * /404.html
file_server
}
It is also possible to limit this to 404 errors, but for the sake of simplicty we will just rewrite all possible errors to /404.html
for now. This has two major downsides:
- It redirects the user, e.g. from
https://example.com/non-existing-path
tohttps://example.com/404.html
. Depending on the use-case this can be the desired behavior, but at least I like it better when the error page is served under the original path. - It drops the error code and sends a 200. When an error occurs Caddy sends the redirect to
/404.html
to the user which is then served like a normal webpage. Caddy handles this as a successful delivery of the404.html
page and therefore sends it with a 200 (success) status code. This is bad. While a human may understand the meaning of the text on the error page, a computer depends on the status code to detect if the request was successfull. Responding with a 200 to an error is a good way to mess up API clients or other software that depends on the status code.
Keeping the status code with respond
The respond
directive of Caddy allows us to set a status code for the response. We can use this in our error handler to keep the status code of the original error:
respond "{http.error.status_code} {http.error.status_text}" {http.error.status_code}
This can be a nice workaround for HTTP endpoints that are mostly consumed by other computers, e.g. API servers. But for actual websites this is rather unsatisfying because it needs the full response hardcoded (or with a minimal template) in the configuration file. So this way the error page can’t follow the same design as the rest of the website.
handle_errors
with try_files
I searched a long time looking for a solution that keeps the original URL, allows a fully customized error page and does not mess with the status code. Unfortunately, I was unable to find anything in the documentation, GitHub issues or the rest of the internet. After a lot of trial and error I found a solution that is surprisingly easy:
handle_errors {
try_files /{http.error.status_code}.html /error.html
file_server
}
When an error occurs while handling https://example.com/non-existing-path
Caddy tries to find an error page matching the status code. If it doesn’t find one it falls back to error.html
. So with a 404.html
file like above the user will now get a customized 404 error page without any redirect (the URL in the browser will still be https://example.com/non-existing-path
) and to make it perfect the response is sent with the correct status code 404.
Wait what, it’s really that easy?
I spent hours looking at complicated solutions that didn’t work and according to a bunch of GitHub issues others struggle with this as well. In the end the solution is so simple, I couldn’t belive it at first. I hope this helps others as well.