# 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), `orders.jsonl.shipping.json` (the cached carrier rate table) and `orders.jsonl.financials.json` (the published running totals behind the public /financials page — see "The open financials page"). Neither payment provider needs stored state — both authenticate with a bearer token per request. **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. (The cached shipping table is deliberately NOT worth backing up: delete it and the next Sendcloud refresh rebuilds it. The financials aggregates file is a few hundred bytes the owner's tooling rewrites — keep a copy with the ledger backup all the same, since nothing on this box can regenerate it.) 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 The deploy job runs on a privileged runner registered to this repository alone (`runs-on: catcrafts-deploy`), not on the shared `arch-latest` runner. crafter-build is the Crafter.Build Flatpak, and bubblewrap inside the job container needs the privilege; imsd's package job is set up the same way. The label is this repository's own on purpose: a job goes to whichever runner carrying its label polls first, and imsd's `flatpak` label is also on the organisation-wide `cx33-org-flatpak` runner (since 2026-10-14), which has no mounts and defaults to an alpine image. Crafter.Build's run 550 (2026-10-16) landed there and failed before building. On the box since 2026-10-12: * `/etc/forgejo-runner/config-catcrafts.yaml`: label `catcrafts-deploy:docker://archlinux:latest` (was `flatpak` until 2026-10-17), `privileged: true`, cache proxy on port 34570. Labels live in this file; the runner declares them at start, so a label change is an edit plus a service restart, never a re-registration. * `forgejo-runner-catcrafts.service`, a copy of the imsd unit, working directory `/var/lib/forgejo-runner-catcrafts`. Its `.runner` is the registration: scope `Catcrafts/catcrafts.net`, name `cx43-catcrafts-net-privileged`. * docker volume `catcrafts-flatpak-system`, mounted at `/var/lib/flatpak` in the job container: the installed Flatpak and its Sdk, about 4 GB, so a deploy downloads neither. Deleting the volume only costs the next run that download. To register it afresh (a lost `.runner`): as the forgejo user in `/var/lib/forgejo`, `forgejo -c /etc/forgejo/app.ini actions generate-runner-token --scope Catcrafts/catcrafts.net`, then as forgejo-runner in the working directory, `forgejo-runner register --no-interactive -c /etc/forgejo-runner/config-catcrafts.yaml --instance https://forgejo.catcrafts.net --token --name cx43-catcrafts-net-privileged`. Inside the crafter-build sandbox the job container's top-level directories are visible at the same paths (`/deploy-app`, `/workspace`, `/opt`, `/root`), but `/tmp` is the sandbox's own: a file a step writes under `/tmp` does not exist for a suite, and the other way round. The media mirror lives on `/deploy-app` and cast is installed under `/opt` for that reason. Both deploy mounts are required, in that runner's config: ```yaml container: privileged: true options: "--volume /var/www/catcrafts.net:/deploy --volume /srv/catcrafts-app:/deploy-app --volume catcrafts-flatpak-system:/var/lib/flatpak" valid_volumes: - /var/www/catcrafts.net - /srv/catcrafts-app - catcrafts-flatpak-* ``` 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 --render /` as `ExecStartPre` before restarting. The unit tests already gated the deploy in CI (`crafter-build test --product=server`); this on-host check covers what CI cannot — the dynamic loader finding libmsquic, libc++, libc++abi and libunwind next to the binary on this machine and a page rendering from the deployed content — so **a broken binary leaves the working server running** instead of replacing it. The binary links the Flatpak's libc++, not the host's, which is why the deploy ships those three libraries into `/srv/catcrafts-app` alongside libmsquic; the rpath from `project.cpp` finds them there. 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. ### The full body, and its inline media Each post is hosted whole at `/posts/`, with the card on `/posts` linking to it — the writing is what the site is about, and a page this site can name as canonical is the only version a search engine can be pointed at. The comments are *not* mirrored: every post page links out to the thread, which is where the discussion belongs. The body stays Markdown in `posts.json` and is rendered by `Catcrafts.Shared:Markdown` at page-render time, never converted to HTML by the shell. That is deliberate: the renderer is inside the escaping guarantee, and text fetched from someone else's server must not be able to become markup anywhere else. Raw HTML in a body is always shown as text. `fetch-media.sh` mirrors what the body embeds as well as the headline file, and rewrites the URLs **inside the Markdown**, so a post page loads nothing third-party either. It also writes a `body_media` list per post — dimensions, poster frame and format renditions for each inline file, which Markdown syntax has nowhere to carry. Slugs come from the title; a duplicate title takes the post's numeric id as a suffix, so an old post's URL is never renumbered by a new one. Re-running the script is a no-op: local paths are adopted from the mount rather than re-fetched. ### The image format ladder Every mirrored still image is transcoded to two siblings named after its content hash, and `Catcrafts.Shared:Media` serves all three as one `` — so the browser fetches **exactly one**: | tier | file | size vs. WebP | who gets it | |---|---|---|---| | `` | `.avif` | **76%** | almost everyone | | `` | `.webp` (the mirrored original) | 100% | Safari 14–16 | | `` | `.png` | **875%** | neither of the above | The middle tier is why the PNG being ~9× the WebP does not matter: it is free (the mirror already downloaded that file) and it is what the small number of non-AVIF browsers actually land on. The PNG is the floor nothing can refuse. AVIF is encoded at `crf 26, cpu-used 6, yuv444p` — measured at SSIM 0.997 against the source and still smaller than it. Full chroma is deliberate: these are screenshots of text, and re-subsampling chroma that pict-rs already subsampled once fringes coloured text visibly, for about 3% more bytes. Encoding is skipped when the sibling is already on the mount, so only genuinely new images cost encoder time (~0.5 s each). Animated sources are left alone entirely — one moving GIF beats three copies of its first frame. Video posters are skipped too: `poster` takes exactly one URL, so a `