# Caddyfile — Phase 9 §9.2 proposed form. # # Three changes vs. /etc/caddy/Caddyfile (2026-05-24): # 1. @static matcher now explicitly excludes /uploads/* — without this, an # uploaded *.jpg matched @static before @gated and slipped past the # forward_auth gate, hitting the SPA build root and returning a public 404. # 2. The security_headers snippet no longer sets Access-Control-Allow-* — # the upstreams' shared/cors/policy.js is the single source of truth for # CORS responses (Phase 6.6). # 3. New @cors_preflight handler punts OPTIONS preflights past forward_auth # so the upstream's CORS middleware can answer them (preflights have no # Authorization header, so they 401'd at the gate previously). # # Apply via the staged-cutover convention in Deviation #8: # scp this file to netcup:/home/matt/Caddyfile.new # curl --silent -X POST -H "Content-Type: text/caddyfile" \ # --data-binary @/home/matt/Caddyfile.new http://localhost:2020/load # # ...smoke-test, then persist: # sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.YYYY-MM-DD # sudo cp /home/matt/Caddyfile.new /etc/caddy/Caddyfile { admin :2020 } (security_headers) { header { X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" X-XSS-Protection "1; mode=block" Strict-Transport-Security "max-age=31536000; includeSubDomains" Referrer-Policy "strict-origin-when-cross-origin" # Phase 9 §9.2: CORS headers removed. Upstreams set ACAO conditionally # via shared/cors/policy.js; Caddy stamping `*` here was overriding it. } } files.acot.site { reverse_proxy localhost:8060 } pbx.acot.site { @ws path /ws handle @ws { reverse_proxy 127.0.0.1:8088 } handle { reverse_proxy 127.0.0.1:8080 { header_up Host {host} header_down Location http://127.0.0.1:8080 https://pbx.acot.site header_down Location http://pbx.acot.site:8080 https://pbx.acot.site } } } turn.acot.site { respond 404 } freescout.acot.site { root * /var/www/freescout/public encode gzip php_fastcgi unix//run/php/php8.3-fpm.sock file_server # Deny access to dotfiles @dotfiles path */.* respond @dotfiles 403 } phone.acot.site { reverse_proxy 127.0.0.1:3020 encode gzip } crafty.acot.site { reverse_proxy localhost:8443 { header_up X-Forwarded-Proto https header_up X-Forwarded-Port 443 header_up Host {host} transport http { tls_insecure_skip_verify } } } cronicle.acot.site { reverse_proxy localhost:3100 { header_up X-Forwarded-Proto https } } inventory.acot.site, acot.site { redir https://tools.acherryontop.com{uri} permanent } tools.acherryontop.com { import security_headers # Public: login endpoint handle /auth-inv/* { uri strip_prefix /auth-inv reverse_proxy localhost:3011 } # Phase 9 §9.2 — CORS preflight bypass. # Browsers send OPTIONS preflights without Authorization, so forward_auth # 401s them. Route preflights straight to the upstream which runs # shared/cors/policy.js and answers correctly. Must come before @static # and @gated so OPTIONS to *.jpg paths under /uploads/* also work if any # frontend ever XHRs an upload URL. @cors_preflight { method OPTIONS header Access-Control-Request-Method * } handle @cors_preflight { handle /api/klaviyo/* { reverse_proxy localhost:3015 } handle /api/meta/* { reverse_proxy localhost:3015 } handle /api/dashboard-analytics/* { reverse_proxy localhost:3015 } handle /api/typeform/* { reverse_proxy localhost:3015 } handle /api/acot/* { reverse_proxy localhost:3012 } handle /chat-api/* { uri strip_prefix /chat-api reverse_proxy localhost:3014 } handle /api/* { reverse_proxy localhost:3010 } } # Public: static frontend assets (long-cache). # Phase 9 §9.2: `not path /uploads/*` ensures uploaded images never get # served from the SPA build root — they must go through @gated below. @static { path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff *.woff2 not path /uploads/* } handle @static { header Cache-Control "public, max-age=2592000" root * /var/www/inventory/frontend/build file_server } # ----- Authenticated zone ----- # Phase 6.1: forward_auth subrequest to auth-server:/verify. 2xx → proceeds. # 401/403 → Caddy returns auth-server response to client; backend never sees it. @gated path /api/* /chat-api/* /uploads/* handle @gated { forward_auth localhost:3011 { uri /verify copy_headers Authorization } # Phase 6.7: /uploads/* now behind the gate (was a public file_server before). # Phase 9 §9.2 closes the static-matcher bypass that made this ineffective. handle /uploads/* { root * /var/www/inventory file_server } # Phase 4: merged dashboard-server (klaviyo + meta + google + typeform). handle /api/klaviyo/* { reverse_proxy localhost:3015 } handle /api/meta/* { reverse_proxy localhost:3015 } handle /api/dashboard-analytics/* { reverse_proxy localhost:3015 } handle /api/typeform/* { reverse_proxy localhost:3015 } # ACOT handle /api/acot/* { reverse_proxy localhost:3012 } # Chat (Phase 9 §9.1 — chat-server now has its own authenticate() too) handle /chat-api/* { uri strip_prefix /chat-api reverse_proxy localhost:3014 } # Catch-all: inventory-server handle /api/* { reverse_proxy localhost:3010 } } # Out-of-band probes (unauthenticated) handle /health { reverse_proxy localhost:3010 } # SPA fallback (public assets) handle { root * /var/www/inventory/frontend/build try_files {path} /index.html file_server encode gzip } handle_errors { respond "{err.status_code} {err.status_text}" } }