# 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. ### 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 `