# 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, uploads to the media mount under its content hash, and prints a `https://catcrafts.net/media/.mp4` URL to paste into the post. `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. 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: * Nothing emits a fallback encoding and `