Caddy Multi-Page Static Site — Diagnose and Fix
When to Use
User reports any of:
- "Click on the nav links doesn't work — they bounce back to hero"
- "Everything redirects to homepage"
- "/skills, /pricing all show the homepage content"
- Sister sites (
/skills/something) return correct content but top-level routes fail
This is almost always one of three Caddy bugs.
⚠ FIRST — confirm it's actually a multi-page site
Before applying anything in this skill, run one check on the dist/ directory:
find dist/ -name 'index.html' | head -20
| Output | Meaning | Action |
|---|---|---|
Multiple files: dist/index.html, dist/skills/index.html, dist/pricing/index.html, ... |
Multi-page (Astro, Hugo, Eleventy, Next static-export) | Use this skill — multi-page try_files {path} {path}/index.html {path}.html |
Single file: just dist/index.html |
SPA (Vite + React Router, Vue Router, Svelte SPA, Solid Router) | WRONG SKILL. Use SPA fallback instead: try_files {path} {path}/index.html /index.html. The trailing /index.html is a feature for SPAs (their router handles the path), an antipattern for multi-page sites (all routes return homepage). Confirmed live trap 2026-04-28: agent-rescue-panel is React Router SPA, applied multi-page Caddyfile from this skill, every non-/ route 404'd because there's literally no dist/skills/index.html to serve. |
The dist/ filesystem is the source of truth — frameworks change names, but if the directory has one index.html it's a SPA, period.
The Three Traps
Trap 1: SPA-style try_files falls back to homepage
❌ Wrong (looks innocent, breaks everything):
handle {
root * /home/user/app/dist
file_server
try_files {path} /index.html
}
try_files resolves left-to-right. For request /skills:
/skills→ not a file (it's a directory in dist) → skip/index.html→ exists → serve homepage (47949 bytes regardless of route)
Per-route HTML files (/skills/index.html, /pricing/index.html) are NEVER tried because /index.html matched first.
✅ Right (multi-page site):
handle {
root * /home/user/app/dist
try_files {path} {path}/index.html {path}.html
file_server
}
Order matters:
{path}— try the literal path (matches static assets like/favicon.ico){path}/index.html— for directory routes (matches/skills/index.html){path}.html— for clean URLs without trailing slash (matches/about.html)
No homepage fallback at all. Astro/Hugo/Eleventy emit a 404.html for missing routes — let Caddy 404 honestly instead of masking with the homepage.
Verification:
# Sizes should DIFFER between routes
for r in "" skills pricing carousel docs; do
printf "%-12s %d bytes\n" "/$r" $(curl -sS https://yoursite.com/$r | wc -c)
done
# Same byte count = SPA-fallback bug. Different bytes = fixed.
Trap 2: Manual caddy process holds the port while systemd is "down"
Symptom:
systemctl is-active caddyreturnsinactiveorfailedlsof -i:PORTshows caddy still listeningsystemctl reload caddysays "Unit cannot be reloaded because it is inactive"systemctl restart caddyfails with "address already in use"
Root cause: someone (or a previous deploy script) ran caddy run --config /etc/caddy/Caddyfile manually as root. That process kept running after the systemd unit was stopped. The two are independent processes; the manual one squats the port.
✅ Fix sequence:
# 1. Find ALL caddy processes
sudo ps -ef | grep caddy | grep -v grep
# root 790207 ... /usr/bin/caddy run --config /etc/caddy/Caddyfile
# 2. Kill the manual one (do NOT kill systemd-managed caddy)
sudo pkill -f 'caddy run'
# 3. Start systemd unit
sudo systemctl start caddy
# 4. Verify
sudo systemctl is-active caddy
sudo lsof -i:3001
# Should show caddy as `caddy` user (NOT root)
Trap 3: caddy user can't traverse user home dir
After fixing Traps 1 + 2, requests return 403 Forbidden. Caddy is running, port is bound, config is valid — but file_server can't read the dist directory.
Cause: /home/wisechef is 750 wisechef:wisechef. The caddy user (uid 999) is not in wisechef group, so it can't enter the directory.
✅ Fix:
# Add caddy to the owner's group
sudo usermod -aG wisechef caddy
# Open execute (traverse) bit on the home dir for the group
sudo chmod g+x /home/wisechef
# Caddy needs a fresh process to pick up new group membership
sudo systemctl restart caddy
# Verify
curl -sS -H 'Host: yoursite.com' http://127.0.0.1:3001/skills -o /dev/null -w '%{http_code} %{size_download}b\n'
# Should return 200 with route-specific byte count
Don't use chmod 755 /home/wisechef — that exposes the user's entire home directory to all other users on the box. Group-membership + g+x is the minimal grant.
Diagnostic Sequence (run this when "links don't work")
Walk the stack from outside-in:
# 1. Per-route HTTP responses
for r in "" skills pricing carousel docs publish signin; do
printf "%-12s %s\n" "/$r" "$(curl -sS -o /dev/null -w '%{http_code} %{size_download}b' https://yoursite.com/$r)"
done
# Pattern interpretations:
# All 200 with SAME byte count → Trap 1 (SPA fallback bug)
# All 403 → Trap 3 (permission)
# All 502/connection refused → caddy down OR port conflict (Trap 2)
# All 200 with DIFFERENT bytes → Caddy is fine; bug is elsewhere
# 2. Check route HTML actually has different H1s
for r in "" skills pricing; do
echo "=== /$r ==="
curl -sS https://yoursite.com/$r | grep -oE '<h1[^>]*>[^<]+' | head -1
done
# 3. Check what's listening on the port
ssh server "sudo lsof -i:3001"
# 4. Check systemd state vs reality
ssh server "sudo systemctl is-active caddy && sudo ps -ef | grep -c '[c]addy run'"
# is-active=active AND ps count=1 → healthy
# is-active=failed AND ps count=1 → Trap 2 (manual process squatting port)
# is-active=active AND ps count=0 → wait, that's impossible (sanity check syntax)
# 5. Read the actual Caddyfile
ssh server "sudo cat /etc/caddy/Caddyfile" | grep -A3 try_files
# Look for `try_files {path} /index.html` — that's Trap 1
Validate Before Reload
Caddy's reload-on-bad-config silently fails sometimes. Always validate first:
sudo caddy validate --config /etc/caddy/Caddyfile 2>&1 | tail -5
# Look for "Valid configuration" — if absent, fix syntax before reload
Vendor the Caddyfile in Repo
Once you've fixed the Caddyfile in production, copy it back into the project repo so the canonical config is version-controlled, NOT just on the server:
ssh server "sudo cat /etc/caddy/Caddyfile" > ~/repos/your-app/ops/Caddyfile
git add ops/Caddyfile && git commit -m "ops: vendor production Caddyfile"
Future deploys can do:
rsync ops/Caddyfile server:/tmp/Caddyfile.new
ssh server "sudo cp /tmp/Caddyfile.new /etc/caddy/Caddyfile && sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy"
Pitfalls
Don't conflate
serve.pywith what's actually running. I once spent 30 minutes editing/home/user/app/serve.pybecausesystemctl cat app.serviceshowed it asExecStart. Butlsof -i:3001showed Caddy was the actual listener — the Python service had failed to start because Caddy held the port. Always verify the listener before editing.Per-route 200 with same byte count is the smoking gun for Trap 1. Don't trust the HTTP status; compare actual bytes/content. Two routes can both be 200 and both be wrong.
{path}/index.htmlrequires trailing slash handling at the client. When user visits/skills(no slash), Caddy serves/skills/index.html. When user visits/skills/, Caddy ALSO serves/skills/index.html. But relative asset URLs in the HTML (<link href="styles.css">) resolve differently for the two —/styles.cssvs/skills/styles.css. Always use absolute paths (/styles.css, leading slash) in your HTML to avoid this.Caddy's
redirdirective runs BEFOREhandle, even ifhandleis listed first in the Caddyfile. Order in the file doesn't equal evaluation order for directives in different categories. Group all redirects at the top to make this explicit.Cloudflare tunnel + Caddy = double 403 confusion. When Caddy returns 403 (Trap 3), Cloudflare wraps it in its own 403 page. The user sees a Cloudflare-branded error and assumes the tunnel is broken. Always test from inside the box first:
curl -H 'Host: yoursite.com' http://127.0.0.1:PORT/route.Caddy log permissions. If you see
open /var/log/caddy/X.log: permission deniedafter a config change, thecaddyuser (under systemd) can't write the log. Fix:sudo chown -R caddy:caddy /var/log/caddy/.
Verification Checklist
After applying fixes:
-
systemctl is-active caddy→active -
lsof -i:PORT→ caddy ascaddyuser, exactly one process -
caddy validate→ "Valid configuration" - All routes return DIFFERENT byte counts via curl
- H1 of each route matches the page (not homepage H1)
- Click test in real browser: clicking nav links lands on different pages
- Caddyfile vendored in
ops/Caddyfile, committed - No
caddy runprocess outside systemd (pgrep -af 'caddy run')
When This Skill Doesn't Apply
- Single-page apps (Vite/Vue/React Router) DO need
try_files {path} /index.html— that's the entire point of SPA hosting. This skill is specifically about MULTI-page static sites where each route has its ownindex.html. - Reverse-proxy-only setups (
reverse_proxy localhost:3000for an Astro SSR build) — those bypassfile_serverentirely. Thetry_filestrap doesn't apply. - Hugo/Eleventy/Astro configured for trailing-slash URLs (
/skills/) — usually no fix needed; the SPA fallback works coincidentally because every route ends in/. Still, prefer the multi-try_filesform for safety.