# Deploying catcrafts.net Two artifacts come out of CI and land in two different places, on purpose. | | goes to | served by | |---|---|---| | wasm bundle + static assets | `/srv/catcrafts.net` | Caddy `file_server` | | `catcrafts-server` + `content/` | `/srv/catcrafts-app` | itself, on `127.0.0.1:8081` | They are kept apart because the web root is **publicly served and mirrored with `rsync --delete`** on every push. Anything runtime-owned that lives there is both published to the internet and destroyed on the next deploy — which matters now for the content files and matters a great deal once the shop has a database and bank credentials. Runtime state goes in a third place, `/var/lib/catcrafts`, created by the service's `StateDirectory=`. Today that is `orders.jsonl` (the order event log) and `bunq-state.json` (the bunq session context, including the client RSA key the server generates on first contact). **Two things on this box cannot be regenerated.** Everything else — the wasm bundle, the content, the binary — comes back from a rebuild. The first is `orders.jsonl`. It is the ledger: every order and every status transition, append-only, and the audit trail the tax records lean on. Back it up: ```sh install -d -m 0700 /var/backups/catcrafts cp /var/lib/catcrafts/orders.jsonl \ /var/backups/catcrafts/orders-$(date -u +%F).jsonl ``` It contains names, addresses and email addresses, so it is personal data: keep it 0600, keep it off the web root, and encrypt it before it leaves the machine. (`bunq-state.json` is deliberately NOT worth backing up: delete it and the server re-onboards from the API key on the next start.) The second is `/srv/catcrafts-app/media` — the mirrored post images and screen recordings. Usually reproducible from `content/posts.json`, but **not if a source instance has deleted the file since**, and for these posts the media *is* the content. It lives on the app mount rather than the web root specifically so `rsync --delete` cannot reach it, and CI only ever adds to it. Worth including in the same backup. ## Why the backend speaks plaintext HTTP/1.1 Caddy terminates TLS and reverse-proxies to loopback. The backend uses Crafter.Network's `ListenerHTTP1` rather than its HTTP/3 `ListenerHTTP` for one concrete reason: **Caddy cannot `reverse_proxy` to an h3 upstream.** Port 8081 must never be exposed directly. ## One-time host setup ```sh # 1. service account, no login, no home useradd --system --no-create-home --shell /usr/sbin/nologin catcrafts # 2. directories mkdir -p /srv/catcrafts.net /srv/catcrafts-app chown catcrafts:catcrafts /srv/catcrafts-app # The web root stays writable by whatever uid the runner container uses. # 3. units cp deploy/catcrafts-server.service /etc/systemd/system/ cp deploy/catcrafts-deploy.service /etc/systemd/system/ cp deploy/catcrafts-deploy.path /etc/systemd/system/ systemctl daemon-reload systemctl enable --now catcrafts-deploy.path # catcrafts-server is started by the first deploy; enable it so it survives a # reboot: systemctl enable catcrafts-server # 4. Caddy — merge deploy/Caddyfile.example into your site config caddy validate --config /etc/caddy/Caddyfile systemctl reload caddy ``` ## Runner configuration Both mounts are required. In the Forgejo runner's `config.yaml`: ```yaml container: options: "-v /srv/catcrafts.net:/deploy -v /srv/catcrafts-app:/deploy-app" ``` The deploy steps fail with an explanatory message if either is missing, rather than silently succeeding into the container's own filesystem. ## How the restart happens CI runs **inside a container** and has no access to the host's systemd. Rather than give the runner host root or a polkit rule — both of which grant far more authority than "restart one service" needs — the last deploy step writes `/srv/catcrafts-app/.deploy-stamp`, and `catcrafts-deploy.path` on the host reacts. `catcrafts-deploy.service` runs `catcrafts-server --selftest` as `ExecStartPre` before restarting. That self-test exercises the HTML-escaping and JSON layers that generate every byte of markup the site emits, so **a broken binary leaves the working server running** instead of replacing it. The binary is installed as `catcrafts-server.new` and `mv`d into place, so a request arriving mid-copy never hits a truncated executable. ## If the backend is down Caddy's `handle_errors` fallback serves the static `index.html`, so the site degrades to the client-rendered wasm app rather than showing a 502. Content still renders; what is lost is server-side rendering and real status codes — an unknown path becomes a soft 404 again until the backend returns. ## Posts and their media `content/posts-sources.json` holds the account and a **community allowlist**. Only listed communities are fetched, so joining a new one does not silently publish it to the site — add a line first. ```sh tools/publish-media.sh FILE # BEFORE posting: uploads to /media, prints the URL tools/fetch-posts.sh # writes content/posts.json tools/fetch-media.sh # mirrors the media, rewrites posts.json to /media/... paths ``` Order matters: the second reads what the first wrote. CI runs both before the build. Neither fails the build on a network error — a fediverse outage leaves the previous `posts.json` in place, and a single failed download leaves that one entry pointing at its original URL rather than losing the post. ### Publish the media first, then post it **The recommended flow is to put a recording on catcrafts.net before writing the post, and use that URL as the post's link.** Run `tools/publish-media.sh recording.mp4`; it transcodes to AV1 *and* an H.264 sibling, uploads both to the media mount under the AV1's content hash, and prints a `https://catcrafts.net/media/.h264.mp4` URL to paste into the post. The H.264 one on purpose: a post's link is fetched raw — Lemmy apps and browsers play that exact file, with no negotiation in front of it — so it must be the encoding everything can play. (An AV1 link posted before this existed drew "bad media error" reports from iPhones within hours.) `fetch-media.sh` recognises its own origin and **adopts** such a URL: it rewrites it to `/media/` and probes the local file for dimensions, downloading nothing. That is not just an optimisation — it removes the whole class of build failure where the media step depends on a third party. A 167 MB recording on a file host once hit `MAX_BYTES` (64 MB), so the entry kept its original URL, and the deploy then failed the e2e "media origin" check on a file that was sitting on our own disk the entire time. Adopting a `.h264.mp4` URL swaps the AV1 sibling back in as the page's primary `` when it is on the mount, keeping the H.264 as the fallback ``. So the post links the compatible file, while browsers that can take AV1 download the small one — the `codecs` parameter on the first source is what lets the rest skip it. The transcode matters as much as the hosting. Phone recordings are wildly oversized for what they show — that same 167 MB clip was 78 s of a dark room at 17 Mbps, and denoising into AV1 gives the same picture in 15 MB. It also **bakes in the rotation**: phones record landscape and attach a display matrix, so an untouched file reports 1920x1080 while playing portrait, and the `width`/`height` attributes then reserve exactly the wrong box. (`fetch-media.sh` swaps the dimensions when it sees a quarter-turn matrix, so a straight-from-phone mirror is correct too — but transcoding means nothing downstream has to know.) Two things to know about publishing AV1: * Browsers without AV1 (Safari before 17, Apple hardware older than A17/M3) get the `.h264.mp4` sibling: on the site via the second ``, on the fediverse because that sibling *is* the posted URL. `--raw` skips the transcode and the fallback both, so a raw AV1 upload recreates the will-not-play problem — use it for files that are already universally playable. * Lemmy's `pict-rs` will not generate a thumbnail from an AV1 file, so a self-hosted video usually arrives with no `poster`. `publish-media.sh` uploads a poster frame beside the video, named `.poster.webp`, and `fetch-media.sh` falls back to that sibling when the instance supplied nothing. A thumbnail the instance *did* provide always wins. (Posting the H.264 URL also means pict-rs can thumbnail it again, so instance thumbnails come back.) An own-origin URL naming a file that is **not** on the mount is deliberately left pointing at its original URL rather than rewritten. That is a post published without its media, and failing the e2e origin check loudly beats shipping a 404 inside a `