catcrafts.net/deploy/Caddyfile.example
Jorijn van der Graaf a8bc400371
All checks were successful
Deploy / build-deploy (push) Successful in 2m33s
video seo
2026-08-19 00:02:32 +02:00

239 lines
12 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
# HSTS. Caddy redirects http->https but does NOT send this header on
# its own, so without it a first visit over http is still interceptable
# and every later one is only as safe as the redirect. A shop taking
# card payments should not be relying on a redirect.
#
# includeSubDomains commits EVERY catcrafts.net subdomain to HTTPS —
# www and forgejo are both on TLS here, so it holds. Drop that token if
# a subdomain ever has to serve plaintext. `preload` is deliberately
# NOT set: submission to the browser preload list is months to undo,
# and it should be a decision, not a side effect of this file.
Strict-Transport-Security "max-age=31536000; includeSubDomains"
# CSP. Defence in depth rather than the primary control — markup is
# built through Catcrafts.Shared:Html, where escaping is enforced by
# the type system and forgetting is a compile error. What this adds is
# the damage limitation that escaping cannot provide:
#
# form-action 'self' the checkout form cannot be retargeted at
# another origin — the directive that matters
# most on a page that collects an address
# frame-ancestors no clickjacking the buy button
# base-uri 'none' an injected <base> cannot re-point every
# relative script src on the page
# object-src 'none' no plugin content, ever
#
# 'unsafe-inline' in script-src is a known and bounded compromise: the
# geo price hint (kGeoPriceHintScript) must run before first paint to
# set a class on <html> without a flash, so it is inline by design.
# Removing it means hashing that constant here and re-hashing on every
# edit — silently breaking the hint when someone forgets. To tighten
# this properly, move the script to a real file and give it a nonce.
# 'wasm-unsafe-eval' is what the WASM runtime needs to compile the
# module; it does not enable eval() for JavaScript.
#
# img-src and media-src allow https: because a post whose media mirror
# failed still points at the source instance's URL (see Media::Describe)
# — locking those to 'self' would blank exactly the pictures a post is
# talking about.
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' https:; font-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
}
# ── 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.
# The icons need it for the same reason the stylesheet does: "/" boots the
# wasm, so its icon fetches happen inside a require-corp document.
#
# -precomposed is listed here even though the rewrite below folds it onto
# apple-touch-icon.png: Caddy's directive order runs `header` BEFORE
# `rewrite`, so this matcher sees the URI as it arrived. (`handle` runs after,
# which is why @static does NOT need to list it.) Drop the path here and the
# probe still 200s, just bare.
header /styles.css Cross-Origin-Resource-Policy "same-origin"
@icons path /favicon.svg /favicon.ico /apple-touch-icon.png \
/apple-touch-icon-precomposed.png
header @icons 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 <bcrypt-hash-here>
}
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=<buildId> in index.html.
#
# CAVEAT for the icons in here: nothing cache-busts them. Clients ask for
# /favicon.svg, /favicon.ico and /apple-touch-icon.png by bare path, so an
# immutable year is exactly how long a redrawn icon takes to reach a repeat
# visitor. Long-standing for favicon.svg; the rasters inherit it deliberately
# rather than diverge. Shorten all three together if that ever bites.
#
# The rewrite is Safari's legacy Home Screen probe, from before it honoured
# <link rel="apple-touch-icon">. What it asks for is byte-identical to
# apple-touch-icon.png, so it is folded onto that rather than committed twice
# — a second copy would also ride in the wasm bundle's VFS and be fetched at
# every boot for nothing. @static below matches the rewritten path.
rewrite /apple-touch-icon-precomposed.png /apple-touch-icon.png
@static path /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js \
/catcrafts-head.js /files.json /variants.json /styles.css \
/favicon.svg /favicon.ico /apple-touch-icon.png \
/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.
# Checkout rate limiting is per-peer, and this block is what makes that
# possible: reverse_proxy APPENDS the real client address to
# X-Forwarded-For, and the backend reads the rightmost entry (see
# ClientAddressFromForwarded — the leftmost is whatever the client claimed).
# That is only sound while nothing but Caddy can reach 8081, which is why
# the backend binds loopback and why the warning at the top of this file
# says not to expose the port.
#
# Caddy's own rate_limit directive is a third-party module and is NOT in a
# standard build — adding it here without rebuilding Caddy stops the server
# from starting. If volume ever justifies limiting at the edge, build Caddy
# with github.com/mholt/caddy-ratelimit first.
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), and is the
# only record of everything those aggregates discard: Sec-Fetch headers,
# Range behaviour, query strings, per-IP request sequences. Every bot fleet
# identified so far was caught in the raw log, not the report.
#
# roll_keep is a COUNT, so retention is really min(roll_keep × roll_size,
# roll_keep_for) — a byte budget, not the time window it reads as. 52 rolls
# is 5.2 GB, which is under a year for anything busier than 14 MB/day, and
# nothing here has ever been that quiet: 19-24 MB/day on a slow week, and
# 617 MB/day at the Hacker News peak, where 52 rolls is EIGHT DAYS. 365
# rolls (~2.6 GB gzipped, at the ~14:1 these compress to) makes
# roll_keep_for the constraint that actually decides, up to ~100 MB/day.
#
# 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 365
roll_keep_for 8760h
}
}
}
# www -> apex. DNS A/AAAA for www point at this box; Caddy obtains the
# certificate automatically. 308 to match the redirs above.
www.catcrafts.net {
redir https://catcrafts.net{uri} 308
}