Table of Contents
Runbook 14: SWAG and the Website
itg-prd-web migration to Fedora bootc, plus the pipeline overhaul it forced (build and bake consolidation, dynamic runner version, the commission workflow). Written for future-me. Durable fixes live in the image and the workflows (git); machine-local secrets live in vault or on the box.
Key facts I keep needing: web VM is itg-prd-web at 10.10.10.83, vmid 206, size S, no data disk. SWAG runs as a rootful podman quadlet, config at /var/lib/containers/swag bind-mounted to /config, PUID/PGID 1001 (the fleet worker user). Site is served from /config/www. The cert is a wildcard *.itguyeric.com via Cloudflare DNS-01. The website deploys over SSH from a Forgejo runner using a restricted rrsync key.
Part 1: itg-prd-web on bootc (SWAG)
The image
Web is deliberately diskless (no data disk). SWAG's /config is small and mostly reproducible: certs reissue via Cloudflare DNS-01, and the static site redeploys from the runner. It lives on machine-local /var, which survives bootc upgrade and is only lost on a full VM re-create. That fits the "not every VM needs a data disk" rule.
bootc/web/ is:
quadlet/swag.container:lscr.io/linuxserver/swag:latest,AutoUpdate=registry,AddCapability=NET_ADMIN, publishes 80 and 443,Volume=/var/lib/containers/swag:/config:Z. Env: PUID/PGID 1001, URL=itguyeric.com, SUBDOMAINS=wildcard, VALIDATION=dns, DNSPLUGIN=cloudflare, plusTimeoutStartSec=900so a first-boot cert issuance does not trip systemd's default start timeout.- tmpfiles creates
/var/lib/containers/swag(root),www(owned by ansible so the deploy key can write it), anddns-conf(owned by worker so certbot reads the token). rsync-rrsyncinstalled (see gotchas).
The ansible web-config role (gated when: app == 'web') renders the Cloudflare token from vault into /config/dns-conf/cloudflare.ini (owner worker), plants the restricted deploy key in ansible's authorized_keys, and starts SWAG.
The migration play
migrate-web.yml, run by hand from the Mac. Phase A stages /config off the old box to the depot, Phase B stops the old VM, Phase C is import_playbook: site.yml to build the new VM and run web-config, Phase D restores /config on top (excluding dns-conf/*.ini so the vault-rendered token wins). Old-box coordinates live in a temporary host_vars/itg-prd-web-old.yml and a web_old inventory group, both deleted after cutover.
Gotchas, in the order they bit
- Stale DNS after an IP change, not Cloudflare. When forge was unreachable but every other subdomain worked, the cause was the Mac's resolver cache still pointing at the old IP. Fix:
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder, and clear Chrome's own cache atchrome://net-internals/#dns.digbypasses the OS cache;dscacheutil -q host -a name <host>shows what the OS is handing apps. Lesson: right after an IP change, suspect the cache before the authoritative record. - rsync missing on the old pet. Phase A failed because the old AlmaLinux box had no rsync.
dnf install -y rsyncon the old box. New bootc boxes get it from base. - rrsync is a separate subpackage. Fedora split it out, so base
rsyncdoes not ship/usr/bin/rrsync. Addrsync-rrsyncto the web image. - The new rrsync rejects
./. The Python rrsync (rsync 3.2.4+) normalizes the client's./destination down to an empty string, then its own safety check fails because the restricted dir does not start with itself-plus-slash. Writing straight into the rrsync root is the thing that breaks. Fix: set the rrsync root to the parent (-wo /var/lib/containers/swag) and have the deploy write into the named subdirwww/. So the authorized_keys line iscommand="/usr/bin/rrsync -wo /var/lib/containers/swag",restrict ...and the deploy rsyncs toansible@itg-prd-web:www/. - www ownership after the restore. Phase D restored the old box's
/configwithrsync -aH, which preserved the old numeric ownership on every subdirectory, so the deploy user (ansible) could not write into them. The tell was Permission denied on mkstemp only in subdirs, never at the top of www. One-timesudo chown -R ansible:ansible /var/lib/containers/swag/www. SWAG still serves fine because it reads as worker (1001) and Hugo's files are world-readable. A fresh provision never hits this (empty ansible-owned www). - Deploy key scope tradeoff. Because the rrsync root moved up to
swag/, the write-only deploy key can technically write anywhere underswag/, not justwww/. Acceptable for a CI-only key, and--deleteis bounded to thewww/target that gets sent. Tighten later by relocating the served directory if it ever matters.
Part 2: The pipeline overhaul
One workflow: pipeline.yml
Consolidated the old separate build.yml and bake.yml into a single pipeline.yml in the bootc repo. Jobs: matrix-prep (decides which images), base (always runs), and image (per-image matrix, fail-fast: false, builds then bakes in one leg).
Behavior by trigger:
- push: builds only the changed image dir(s), no bake, because running VMs just
bootc upgrade. A change to a base file (root Containerfile, config.toml, images.json, files/) rebuilds everything. - nightly (4 AM Central cron): builds and bakes every image.
- manual dispatch with
image: <dir-or-name>orall: builds and bakes that selection.
Why build and bake are one job per image and not two jobs: per-image independence. If one image's build step fails, only its bake is skipped and every other image still builds and bakes, because fail-fast: false keeps the matrix legs apart. Two separate jobs would be all-or-nothing (a single failed build skips every bake), because Actions job dependencies cannot be scoped to a single matrix leg. The only hard gate is base: if base fails, nothing downstream runs, which is correct. The visual cost is one line per image in the UI instead of separate build and bake lines; that was an accepted tradeoff.
Dynamic runner version
Stopped hardcoding the Forgejo runner version. The run image resolves the latest release at build time from the Forgejo API (/releases/latest, parsed with python3) and downloads that binary. ARG RUNNER_VERSION=latest with an override to pin an exact version if a release ever misbehaves. Note /releases/latest is already latest-stable (it excludes drafts and prereleases); it is not a bleeding-edge tag. This is how the runners moved to 13.0.0 on their own.
commission.yml (day-0)
commission.yml in the infra repo is the one-stop for a brand-new node: one dispatch builds the image, bakes the qcow2, and provisions the VM. It checks out the bootc repo cross-repo for the build, then runs the ansible provision. Optional recreate: true deletes the VM first (via retire.yml) for a clean-slate rebuild during bring-up. Steady-state stays on pipeline.yml plus bootc upgrade; commission is only for birthing a host. Next customer is the Matrix server.
Secrets
No organization (personal account), so FORGE_TOKEN lives as a user-level Actions secret (user Settings, Actions, Secrets), inherited by every repo I own. It is a personal access token carrying write:package (registry push and pull) AND read:repository (commission's cross-repo checkout). A package-only token authenticates the registry but fails commission's checkout.
ansible.posix on the runners
web-config uses ansible.posix.authorized_key, so the run image bakes ansible.posix next to community.proxmox (ansible-galaxy collection install community.proxmox ansible.posix). Collections are baked into the image, not installed per job, so a new collection means rebuilding the run image and bootc upgrade on the runners (one at a time so a runner is always available).
Forgejo/act quirks worth remembering
- Matrix vars do not interpolate in step
name:fields. They work in the jobname:and inrun:, but a step name likeBuild ${{ matrix.image }}renders literally in the UI. Use static step names. - Skipped
needsplusalways()is fragile. A job whoseneeds:dependency was skipped gets skipped itself, even withif: always(), andneeds.*.resulthandling is unreliable on act. That is whybasealways runs (it is a cache-cheap hit when unchanged) instead of being gated, and why every job-levelifsticks to plain string checks likeneeds.matrix-prep.outputs.images != '[]'.
Status
tail, game, web, and plex are all on bootc. Arr is the last old VM. The GPU resource-mapping lessons from the plex migration (Mapping.Use needed on both the user and the token, and the mapping must carry path, id, iommugroup, and subsystem-id) belong in the Runbook 12 update, not here.
Runbooks
The build
- Image Mode & Base
- Storage
- Hostnames & DNS
- Registry
- Actions Runner
- The Pipeline
- Nightly Auto-Deploy
- First Workload
- VSCode Cockpit
- Hugo Auto-Deploy
- Cloudflare & Kobo
- Runner Provisioning
- Tailscale Router
- SWAG & Website
- Split-DNS
- Plex
- Media Library Support
- The Day After
- Self-Hosted Media
- Hypervisor Joins the Fleet
- The Matrix Homeserver
Reference