# catcrafts.net Caddy site block # # Two upstreams: Caddy's file_server for build artifacts, and catcrafts-server # for everything else. Point `root` at the host directory you bind-mount into # the runner as /deploy (the "-v /path/to/webroot:/deploy" in the runner's # config.yaml). # # catcrafts-server speaks PLAINTEXT HTTP/1.1 on localhost — Caddy terminates # TLS. That is also why the backend uses Crafter.Network's ListenerHTTP1 rather # than its HTTP/3 listener: Caddy cannot reverse_proxy to an h3 upstream. # Do not expose port 8081 directly. catcrafts.net { root * /srv/catcrafts.net encode zstd gzip header { Referrer-Policy "strict-origin-when-cross-origin" X-Content-Type-Options "nosniff" -Server } # ── cross-origin isolation, scoped ──────────────────────────────────── # # The WASM runtime needs a cross-origin-isolated context (SharedArrayBuffer # / threads), and these three headers are what provide it. They are NOT # optional on a page that boots the module — without them it loads and the # runtime fails. # # But they are scoped to the paths that actually load it, rather than applied # site-wide. COEP: require-corp blocks every cross-origin subresource that # does not opt in, so applying it to pages that have no wasm would constrain # them for no benefit — and the shop's payment pages later must not inherit # that restriction. @isolated path /demos/* /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js \ /catcrafts-head.js /files.json /variants.json /*.wgsl header @isolated { Cross-Origin-Opener-Policy "same-origin" Cross-Origin-Embedder-Policy "require-corp" Cross-Origin-Resource-Policy "same-origin" } # Subresources an isolated document pulls in must carry CORP themselves. header /styles.css Cross-Origin-Resource-Policy "same-origin" header /favicon.svg Cross-Origin-Resource-Policy "same-origin" # ── analytics, two tiers ────────────────────────────────────────────── # # GoAccess reports rebuilt hourly by catcrafts-analytics.timer from the # access logs below. Server-side only: no client-side analytics anywhere # on the site, per the privacy policy (request logging only). # # /analytics/ is public and censored - visitor IPs are anonymized at # ingest, there is no host panel, and sensitive paths never enter its DB # (see CENSOR_RE in deploy/catcrafts-analytics). /analytics/private/ is # the uncensored report. Generate the hash with `caddy hash-password`. redir /analytics /analytics/ 308 redir /analytics/private /analytics/private/ 308 # The privacy policy's "view previous versions" promise. A redirect so # the policy text stays short and survives repo or path moves - update # the target here, never the policy wording. redir /legal/privacy/history https://forgejo.catcrafts.net/Catcrafts/catcrafts.net/commits/branch/master/shared/interfaces/Catcrafts.Shared-Content.cppm 302 handle_path /analytics/private/* { basic_auth { jorijn } root * /var/www/analytics-private header Cache-Control "private, no-store" file_server } handle_path /analytics/* { root * /var/www/analytics header Cache-Control "public, max-age=600" file_server } # ── build artifacts: served from disk ───────────────────────────────── # # file_server does sendfile, precompressed variants and range requests far # better than the backend would. `precompressed` serves the .zst / .gz # siblings the CI build produces, so the ~800 KB module is never recompressed # per request. Cache-busted by the ?v= in index.html. @static path /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js \ /catcrafts-head.js /files.json /variants.json /styles.css \ /favicon.svg /robots.txt /*.wgsl /*.jpg /posts.json /rates.json handle @static { header Cache-Control "public, max-age=31536000, immutable" file_server { precompressed zstd gzip } } # ── mirrored post media ────────────────────────────────────────────── # # Deliberately NOT under the web root: that directory is mirrored with # `rsync --delete` on every deploy, and this media is not always # reproducible — if a source instance deletes a file, our copy is the only # one left. Living on the app mount puts it physically outside the delete. # # Filenames are the content hash, so a changed file gets a new name and the # immutable cache lifetime is honest. handle_path /media/* { root * /srv/catcrafts-app/media header Cache-Control "public, max-age=31536000, immutable" file_server } # ── everything else: server-rendered ───────────────────────────────── # # Pages, /feed.xml, /sitemap.xml and later /api/*. The backend sets its own # Cache-Control and returns real status codes — a 404 for an unknown path and # a 301 for the retired /blog URLs, which a client-side router cannot do. handle { reverse_proxy 127.0.0.1:8081 { health_uri /api/healthz } } # If the backend is down, fall back to the static wasm shell so the site # degrades to a client-rendered app rather than a bare Caddy error page. # This must be handle_errors, not handle_response: handle_response only # fires on responses an upstream actually sent, and a backend that is not # running refuses the connection — that is an error, which only # handle_errors sees (verified against a dead port). The error status is # kept, so a crawler sees the honest 502 while a human gets the working # client-rendered app; only SSR and real 404s are lost. handle_errors 502 503 504 { rewrite * /index.html header Cache-Control "no-store" file_server } # ── access log ──────────────────────────────────────────────────────── # # Feeds the GoAccess analytics (see deploy/catcrafts-analytics). Retention # sized for that: ~15 MB/day means a roll roughly weekly; 52 compressed # rolls ~ a year of raw logs (~6 MB each gzipped). roll_keep_for must be # explicit — lumberjack's default silently deletes after 90 days. log { output file /var/log/caddy/catcrafts.net.log { roll_size 100MiB roll_keep 52 roll_keep_for 8760h } } }