The 404 That Cost Me Two Hours
I was setting up a static blog on Caddy. The articles lived in a posts/ subdirectory, and I wanted clean URLs like /posts/my-article.html. Simple enough, right? I wrote what looked like a perfectly reasonable Caddyfile, reloaded, and got... 404 on every single article. The homepage worked fine. The files were definitely on disk. But every article URL returned "Not Found."
Two hours of debugging later, I discovered the problem was a single word: I used handle_path when I should have used handle. That one word was the difference between a working blog and a wall of 404s. This is the Caddy routing mistake that almost everyone makes at least once, and the official docs don't warn you about it clearly enough.
handle vs handle_path: The Critical Difference
Here's the core issue. Both handle and handle_path match URL prefixes and serve files. But they do one thing differently that changes everything:
| Directive | What it does | Example |
|---|---|---|
handle /posts/* | Matches prefix, keeps the full path when looking up files | Request /posts/foo.html → looks for {root}/posts/foo.html |
handle_path /posts/* | Matches prefix, strips the prefix before looking up files | Request /posts/foo.html → looks for {root}/foo.html |
Read that again. handle_path removes the matched prefix from the file lookup path. handle keeps it. That's the entire difference, and it's the source of countless 404 errors.
The Mistake in Action
Here's the exact broken configuration I started with:
agentechip.com {
root * /var/www/devbytes
# WRONG: strips /posts/ prefix, but files ARE in posts/ subdirectory
handle_path /posts/* {
file_server
}
handle {
file_server
}
}
My files were on disk at:
/var/www/devbytes/posts/article-1.html
/var/www/devbytes/posts/article-2.html
/var/www/devbytes/index.html
When a request came in for /posts/article-1.html, here's what happened:
handle_path /posts/*matched the URL prefix- It stripped
/posts/from the path - Caddy looked for
/var/www/devbytes/article-1.html(withoutposts/) - File not found → 404
The fix was changing one word:
agentechip.com {
root * /var/www/devbytes
# CORRECT: keeps /posts/ prefix, files found in posts/ subdirectory
handle /posts/* {
file_server
}
handle {
file_server
}
}
Now the lookup path is /var/www/devbytes/posts/article-1.html — file found, 200 OK.
When to Use Which
The rule is simple once you understand it:
Use handle when files are in a subdirectory matching the URL prefix
# Files at /var/www/site/blog/posts/*.html
# URLs at /blog/posts/*.html
handle /blog/posts/* {
root * /var/www/site
file_server
}
Use handle_path when you want to map a URL prefix to a different directory structure
# Files at /var/www/site/*.html (NOT in a blog/ subdirectory)
# URLs at /blog/*.html
handle_path /blog/* {
root * /var/www/site
file_server
}
In the second case, handle_path strips /blog/ so that /blog/index.html maps to /var/www/site/index.html. Without stripping, Caddy would look for /var/www/site/blog/index.html, which doesn't exist.
The Debugging Process
If you're getting 404s with Caddy, here's the systematic approach that found my problem:
Step 1: Verify the file exists
ls -la /var/www/devbytes/posts/article-1.html
# If this fails, the problem isn't Caddy routing — the file isn't there
Step 2: Test with curl and check the exact path Caddy is looking for
# Enable debug logging temporarily
caddy adapt --config /etc/caddy/Caddyfile --pretty
# Or check Caddy's file access in real-time
strace -e trace=openat -p $(pgrep caddy) 2>&1 | grep -i "article-1"
Step 3: Manually trace the path mapping
# If your Caddyfile has:
# handle_path /posts/* → root /var/www/devbytes
# Then a request for /posts/foo.html maps to:
# /var/www/devbytes/foo.html (prefix stripped)
#
# If it has:
# handle /posts/* → root /var/www/devbytes
# Then the same request maps to:
# /var/www/devbytes/posts/foo.html (prefix kept)
Caddy's Silent Cache: The Second Gotcha
After fixing the routing, I reloaded Caddy and... still got 404s. The configuration was correct, the files were there, but Caddy was still serving the old behavior. The fix?
systemctl restart caddy # NOT reload — full restart
Caddy's reload command does a graceful config swap, but in some cases (especially with file server routes), the old file handles or cached directory listings can persist. When in doubt, do a full restart. This cost me another 30 minutes of confusion before I figured it out.
Caddy Access Logs: Enable Them From Day One
Here's something else that bit me: Caddy doesn't write access logs by default. When I wanted to check if anyone was actually visiting the site, I had nothing to look at. Enable access logging explicitly:
agentechip.com {
root * /var/www/devbytes
encode gzip
log {
output file /var/log/caddy-access.log
format json
}
handle /posts/* {
file_server
}
handle {
file_server
}
}
Then reload and you get JSON access logs you can parse with Python or jq:
# Top 10 requested paths
cat /var/log/caddy-access.log | python3 -c "
import json, sys, collections
paths = collections.Counter()
for line in sys.stdin:
try:
d = json.loads(line)
paths[d.get('request',{}).get('uri','')] += 1
except: pass
for p, c in paths.most_common(10):
print(f'{c:5d} {p}')
"
Common Caddy Routing Mistakes (Beyond handle vs handle_path)
1. Semicolons in Caddyfile
Caddy uses one directive per line. Semicolons are not valid separators:
# WRONG — parsing error
root * /var/www/site; file_server
# CORRECT
root * /var/www/site
file_server
2. Forgetting to validate before reload
# Always validate first
caddy validate --config /etc/caddy/Caddyfile
# Then reload
systemctl reload caddy
3. file_server inside handle_path without its own root
# WRONG — file_server inherits the outer root, but path is stripped
handle_path /blog/* {
file_server # Uses outer root, path already stripped — might not find files
}
# CORRECT — set root explicitly inside the block
handle_path /blog/* {
root * /var/www/blog-content
file_server
}
Conclusion
The handle vs handle_path confusion is arguably Caddy's most common pitfall. The rule to remember: if your files are in a subdirectory that matches the URL prefix, use handle. If you want to map a URL prefix to a different directory, use handle_path.
And always, always enable access logs from day one. You can't debug what you can't see.