Skip to content
All skills
COOK ops v1.0.0 · Apache-2.0

Caddy Multipage Static Deploy

Diagnose and fix Caddy serving a static multi-page site (Astro, Next static-export, Eleventy, Hugo) where every route returns the homepage. Covers the SPA-fallback `try_files` trap, the silent-no-op port conflict (manual caddy process holding the port while systemd thinks it's down), and the caddy-user-cannot-traverse-home-directory permission trap. Use when "links don't work," "all routes return same content," or "caddy says active but pages 404 / serve wrong content.

Audited
Source
SHA-256
Last reviewed
How we audit →

Install in your agent

Tell your agent: "install the recipes skill, then add caddy-multipage-static-deploy"
Or via curl: curl -sL https://recipes.wisechef.ai/skill -o ~/.claude/skills/recipes/SKILL.md

Full skill source · SKILL.md

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:

  1. /skills → not a file (it's a directory in dist) → skip
  2. /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 caddy returns inactive or failed
  • lsof -i:PORT shows caddy still listening
  • systemctl reload caddy says "Unit cannot be reloaded because it is inactive"
  • systemctl restart caddy fails 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

  1. Don't conflate serve.py with what's actually running. I once spent 30 minutes editing /home/user/app/serve.py because systemctl cat app.service showed it as ExecStart. But lsof -i:3001 showed Caddy was the actual listener — the Python service had failed to start because Caddy held the port. Always verify the listener before editing.

  2. 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.

  3. {path}/index.html requires 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.css vs /skills/styles.css. Always use absolute paths (/styles.css, leading slash) in your HTML to avoid this.

  4. Caddy's redir directive runs BEFORE handle, even if handle is 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.

  5. 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.

  6. Caddy log permissions. If you see open /var/log/caddy/X.log: permission denied after a config change, the caddy user (under systemd) can't write the log. Fix: sudo chown -R caddy:caddy /var/log/caddy/.

Verification Checklist

After applying fixes:

  • systemctl is-active caddyactive
  • lsof -i:PORT → caddy as caddy user, 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 run process 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 own index.html.
  • Reverse-proxy-only setups (reverse_proxy localhost:3000 for an Astro SSR build) — those bypass file_server entirely. The try_files trap 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_files form for safety.