Error Pages #
Professionally designed error pages are a crucial element in building a mature user experience (UX) on a website. When visitors hit a broken link or the server has technical trouble in the background, showing raw browser-default error messages (like a plain white screen saying “Internal Server Error”) can confuse visitors and drive them away immediately. A user-friendly error page can guide visitors back to the right navigation path, suggest alternative pages, or provide help contact info.
Caddy provides a very powerful error handling engine through the handle_errors directive. This module lets Caddy intercept error responses (both 4xx and 5xx status codes), stop the normal routing process, and redirect traffic internally to serve a custom error page. This article dives deep into how handle_errors works, the error placeholder variables you can use, dynamic error page design techniques, and error monitoring integration with trace IDs.
How the handle_errors Block Works in Caddy
#
One crucial thing that distinguishes Caddy’s error handling from traditional web servers is that handle_errors does not perform an external HTTP 302/307 redirect to the browser. The interception process runs entirely inside Caddy’s memory:
flowchart TD
Client["Client Request"] --> MainHandler["Main Handler"]
MainHandler --> Error["Error Occurs (404 / 500 / 502)"]
Error --> Eval["Evaluate handle_errors"]
Eval --> Send["Send Response"]
Send --> Browser["Browser Stays on the Original URL<br/>(Original status preserved for SEO)"]Because the rerouting runs internally, the visitor’s browser stays on the URL they typed earlier and receives the original status code (like 404 or 500) along with your custom HTML document. This is very important for SEO compliance; if the server redirects a 404 error to the /404.html URL with an HTTP 200 status, search engines like Google index that error page as normal content, which can damage your site’s search ranking.
Basic Configuration #
To enable custom error pages, you declare a handle_errors block at the end of your site block. The basic Caddyfile below shows how to serve custom HTML files based on the error status code:
example.com {
root * /var/www/html
file_server
# Error handling block
handle_errors {
# 1. Switch the base directory to the error page folder
root * /var/www/errors
# 2. Rewrite all request URIs to the status code filename (e.g., /404.html)
rewrite * /{err.status_code}.html
# 3. Serve static files from the new root
file_server
}
}
Recommended Error File Directory Structure: #
On your server, you can create a dedicated /var/www/errors/ folder to store various error page design variations:
/var/www/errors/
├── 400.html # Bad Request
├── 401.html # Unauthorized
├── 403.html # Forbidden
├── 404.html # Not Found
├── 429.html # Too Many Requests (Rate Limited)
├── 500.html # Internal Server Error
├── 502.html # Bad Gateway / Backend Down
└── 503.html # Service Unavailable / Maintenance
Error Placeholders Provided by Caddy #
When inside a handle_errors block, Caddy injects several special placeholder variables containing detailed information about the error that just occurred. You can use these variables to render error pages dynamically or log them:
| Placeholder | Value Type | Description |
|---|---|---|
{err.status_code} | Integer | The HTTP error status code (like 404, 403, 500, 502). |
{err.status_text} | String | The standard HTTP short description (like "Not Found", "Internal Server Error"). |
{err.message} | String | The detailed error message from Caddy’s internal module (useful for admin analysis). |
{err.trace} | String | A unique trace ID for correlating with server log files. |
{err.id} | String | The unique error ID generated by the HTTP module. |
Separating Error Page Logic by Status Code #
In real-world scenarios, you want to display very different messages for client-side errors (4xx) versus server-side errors (5xx). 4xx errors are usually caused by users mistyping a URL, while 5xx errors are the server administrator’s responsibility.
You can use expression matchers inside the handle_errors block to separate the handling logic:
example.com {
root * /var/www/html
file_server
handle_errors {
# Define expression matchers based on the status code
@404 expression {err.status_code} == 404
@5xx expression {err.status_code} >= 500
# 1. Special 404 Page Handling
handle @404 {
root * /var/www/html
rewrite * /404.html
file_server
}
# 2. Server Error Page Handling (5xx)
handle @5xx {
root * /var/www/errors
# Use the unified 500 template
rewrite * /500.html
file_server
}
# 3. Default Handling for Other Status Codes (e.g., 403, 401)
handle {
respond "System Error ({err.status_code}): {err.status_text}" {err.status_code}
}
}
}
Designing Dynamic Error Pages Using the templates Directive
#
Instead of creating dozens of different static HTML files for each status code, you can create one dynamic HTML file using Caddy’s template feature. The templates directive tells Caddy to process HTML files with the Go template engine before sending them to the user, letting you use the placeholder function to display data dynamically.
Caddyfile Configuration: #
example.com {
root * /var/www/html
file_server
handle_errors {
root * /var/www/errors
# Enable the template engine
templates
rewrite * /dynamic-error.html
file_server
}
}
Dynamic HTML Template File (dynamic-error.html):
#
Here’s a modern, responsive error page implementation using an eye-catching gradient background that displays messages customized dynamically based on the error status code:
<!-- /var/www/errors/dynamic-error.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error {{placeholder "err.status_code"}} — {{placeholder "err.status_text"}}</title>
<style>
:root {
--primary: #4f46e5;
--primary-hover: #4338ca;
--bg-gradient: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
--card-bg: #ffffff;
--text-main: #1e293b;
--text-muted: #64748b;
}
@media (prefers-color-scheme: dark) {
:root {
--primary: #6366f1;
--primary-hover: #4f46e5;
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
--card-bg: #1e293b;
--text-main: #f8fafc;
--text-muted: #94a3b8;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg-gradient);
color: var(--text-main);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.card {
background-color: var(--card-bg);
border-radius: 16px;
padding: 3rem 2rem;
max-width: 480px;
width: 100%;
text-align: center;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
}
.code {
font-size: 5.5rem;
font-weight: 800;
color: var(--primary);
line-height: 1;
margin-bottom: 0.5rem;
}
.title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1rem;
}
.message {
color: var(--text-muted);
font-size: 1rem;
margin-bottom: 2rem;
line-height: 1.6;
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
text-decoration: none;
transition: background-color 0.2s;
}
.btn-primary {
background-color: var(--primary);
color: #ffffff;
}
.btn-primary:hover { background-color: var(--primary-hover); }
.btn-secondary {
background-color: rgba(0,0,0,0.05);
color: var(--text-main);
}
@media (prefers-color-scheme: dark) {
.btn-secondary { background-color: rgba(255,255,255,0.05); }
}
.btn-secondary:hover { background-color: rgba(0,0,0,0.1); }
.trace {
margin-top: 2.5rem;
font-size: 0.75rem;
color: var(--text-muted);
font-family: monospace;
border-top: 1px solid var(--text-muted);
padding-top: 1rem;
opacity: 0.7;
}
</style>
</head>
<body>
<div class="card">
<div class="code">{{placeholder "err.status_code"}}</div>
<div class="title">{{placeholder "err.status_text"}}</div>
<div class="message">
<!-- Evaluate custom conditions per status code -->
{{if eq (placeholder "err.status_code") "404"}}
The page you're looking for can't be found. The link may have expired or the URL you entered is incorrect.
{{else if eq (placeholder "err.status_code") "403"}}
Access denied. You don't have administrative permission to read the folder or file at this path.
{{else if ge (placeholder "err.status_code") "500"}}
There's a problem with our server system. Our infrastructure team has been notified and is fixing it promptly.
{{else}}
An unexpected system error occurred. Please try again in a moment.
{{end}}
</div>
<div class="actions">
<a href="/" class="btn btn-primary">🏠 Go to Home</a>
<a href="javascript:history.back()" class="btn btn-secondary">← Back</a>
</div>
<!-- Print the Trace ID to make admin tracking easier for internal server errors -->
{{if ge (placeholder "err.status_code") "500"}}
<div class="trace">
Trace ID: {{placeholder "err.trace"}}
</div>
{{end}}
</div>
</body>
</html>
Error Handling for Reverse Proxy Services (Gateway Failures) #
When Caddy acts as a reverse proxy in front of an application server (like Node.js, a Go backend, PHP-FPM, or Python/Django), failures can happen at two levels:
- Errors from Caddy (Gateway level): The backend application is completely dead, so Caddy can’t connect (connection refused) and is forced to produce the 502 Bad Gateway status code.
- Errors from the Application: The backend is alive, but the application code crashed and sent a 500 status code response to Caddy.
By default, Caddy forwards a 500 response from the backend as-is without interception. However, you can configure Caddy to intercept both backend errors and Caddy’s own connection failures to unify their appearance using handle_errors:
app.example.com {
# Intercept 502/503/504 connection errors from the proxy
handle_errors {
@gateway_error expression {err.status_code} in [502, 503, 504]
handle @gateway_error {
root * /var/www/errors
rewrite * /maintenance.html
file_server
}
# Handling for other common errors
handle {
root * /var/www/errors
rewrite * /generic-error.html
file_server
}
}
# Main reverse proxy configuration
reverse_proxy localhost:3000 {
# Enable connection failure detection
health_uri /healthz
health_interval 10s
}
}
Unified Maintenance Mode Pattern #
When your infrastructure team needs to do scheduled server maintenance, you can cut off all traffic and route it to a custom maintenance page using the 503 status code:
example.com {
# Enable the line below while maintenance is in progress
# respond "System Maintenance" 503
root * /var/www/html
file_server
handle_errors {
@maintenance expression {err.status_code} == 503
handle @maintenance {
root * /var/www/maintenance-pages
rewrite * /index.html
file_server
}
}
}
Trace ID Integration with Server Logs #
When a user reports a “500 Internal Server Error”, it’s very hard to track down the exact cause without knowing which log line recorded the error. Caddy makes this correlation easy using the Trace ID ({err.trace}).
Caddy automatically records this Trace ID in its JSON access log files. On your error page (like the dynamic template example above), you print that Trace ID on screen. When users report the issue to technical support, they just copy the Trace ID string.
Your system admin can then instantly search the server for the exact cause:
# Search for the specific error cause in the log using the Trace ID
grep "string-trace-id-from-user" /var/log/caddy/access.log
The JSON log response shows exactly the internal error type, the upstream IP address, and the database status at the time of the connection problem.
Summary #
- Internal Interception — The
handle_errorsdirective intercepts error responses and reroutes the process flow internally without changing the URL in the browser (preserving SEO health).- Tracking Variables — The
{err.status_code}and{err.trace}placeholders are very useful for displaying dynamic information and facilitating admin log tracking.- Dynamic Templates — Combining the
templatesdirective and theplaceholderfunction in HTML lets you design one unified error page file for all status codes.- Reverse Proxy Failures — Caddy can intercept gateway errors (like 502/503 when the backend dies) to serve custom maintenance pages to visitors.
- Log Correlation — Printing the Trace ID on server error pages (5xx) speeds up the fix process by matching that ID in the server’s JSON access logs.