Caddy’s Caddyfile is one of the most pleasant config formats I’ve used. So when it bites, it bites quietly — because the docs describe each directive perfectly, in isolation, and the trouble lives in how directives behave next to each other, inside each other, or somewhere the docs assume they aren’t.
Here’s the lesson up front, because it’s the part worth keeping: a config that passes validation isn’t always the config that runs, and “reloaded without error” isn’t the same as “doing what you meant.” Three of the four traps below don’t fail loudly — they reload clean and serve the wrong thing.
So this is a field guide to the four, with the fix for each, and the one habit that would have caught every one of them.
The short version
caddy validateignores your env file → validate after sourcing the same env, or trustreload. Translation: the pre-flight check can pass a config that isn’t the one that actually runs, so don’t trust it on its own.respond <placeholder> { body }→ “body already specified” → use the positionalrespond "<body>" <status>. Translation: the error blames the wrong thing — write the response on one line instead of in a block.handle_errorswon’t nest inhandle/handle_path→ hoist it to the site block; match the pre-strip URL. Translation: custom error pages are set once for the whole site, not per route, so move them to the top level.redir <path> <status>insidehandlesilently becomes a 302 → giveredira full destination URL. Translation: your “permanent” redirect quietly ships as a temporary one with no warning — use the full web address as the target.
1. caddy validate doesn’t see your environment
What you’ll see. You inject values with {$VARS} from a systemd EnvironmentFile, run caddy validate before reloading, and it dies on a line that’s obviously fine.
Why. validate runs in your shell, not under the systemd unit — so it never reads the EnvironmentFile. Every {$VAR} becomes an empty string, and the config it checks isn’t the config that will run.
The fix. Source the same environment first:
set -a; . /etc/yourapp.env; set +a
caddy validate --config /etc/caddy/Caddyfile
Rule of thumb. validate and “what systemd runs” are different configs unless you make them the same. caddy reload runs under the unit — when in doubt, that’s the honest check.
2. respond with a placeholder status eats your body
What you’ll see. You want a dynamic status, write the obvious thing, and Caddy refuses it with body already specified — though you specified it once:
respond {err.status_code} {
body "Something went wrong."
}
Why. respond takes a body and a status as positional args. A leading placeholder gets read as the body positional; the body subdirective then looks like a second body.
The fix. Drop the block, use the positional form — body first, status second:
respond "Something went wrong." {err.status_code}
Rule of thumb. When a placeholder leads a directive’s arguments, prefer the positional form over a block. Blocks are where the ambiguity hides.
3. handle_errors won’t nest inside handle / handle_path
What you’ll see. You nest a handle_errors block inside a route to get local error handling, and Caddy rejects the config.
Why. handle_errors is a site-block-level directive — one error chain per site, not one per route. It can’t live inside handle or handle_path.
The fix. Hoist it to the site block. The catch: handle_path strips the matched prefix, but your hoisted error matchers see the original request path:
example.com {
handle_path /api/* {
reverse_proxy localhost:9000
}
handle_errors {
# this matcher sees /api/..., not the stripped path
respond "API unavailable." {err.status_code}
}
}
Rule of thumb. Error handling is per-site, not per-route. After a prefix strip, error matchers still think in the original URL.
4. redir inside handle silently becomes a 302
What you’ll see. Nothing — that’s the problem. You write an exact-path permanent redirect:
handle /old {
redir /new 301
}
validate passes, reload passes, the site comes up green — and serves a 302, not your 301.
Why. Inside a handle, redir <path> <status> is misparsed: the path becomes an inner matcher, the status becomes the destination, and the real status defaults to a temporary 302. No error anywhere; only the wire shows it:
curl -sI https://example.com/old
# HTTP/2 302 <- wanted 301
The fix. Give redir a full URL as its destination:
handle /old {
redir https://example.com/new 301
}
Rule of thumb. A silent default is worse than an error. For redir, always pass a full destination URL.
The one habit that catches all four
Three of these four — including the worst — passed validation and reloaded clean. That’s the pattern worth internalizing: reloaded without error is not the same as doing what you meant. The config that passes isn’t always the config that runs; the directive you wrote isn’t always the directive that got parsed. The response on the wire is the only source of truth.
So make the last step of every deploy a thirty-second reality check:
curl -sI https://example.com/route-that-matters
Read what actually came back. Validation tells you the Caddyfile parses. Only the wire tells you it’s right.
Aside — the war story, since it earned a place. I collected all four in a single weekend taking Project Sentinel to its first public alpha. Three bit back-to-back, and the redir one quietly served a 302 in place of a 301 while every tool reported success. Irritating at the time — but the habit above is the part I’d keep if I forgot everything else.
# comments (0)
no comments yet — be the first.