# /etc/systemd/system/cashumints-web.service # # The frontend is static: `output: 'static'` in astro.config.mjs, and the whole site is # produced ahead of time. There is no frontend build to keep alive, so this unit is a # build rather than a daemon — one shot of `pnpm build`, which compiles shared/, renders # a social card per mint and prerenders every page from the live API. The daemon that # hands the result out is cashumints-site.service. # # Run it after a deploy, and only after a deploy: # sudo systemctl start cashumints-web # # There used to be a cashumints-web.timer firing this at 03:30 every night, because the # mint list was a snapshot of whatever the API held when the build ran and a nightly # rebuild was the only way it ever changed. The list hydrates from the API after paint # now, so a new mint, a new review count and a changed status all reach the page within # a second of load, and rebuilding 2,000 pages at 03:30 to refresh numbers that refresh # themselves is 20 minutes of CPU for nothing. # # What a build still produces, and therefore what a deploy is still for: the prerendered # HTML a crawler reads, the social card per mint, the sitemap, and a `/mint/{host}` page # for every mint known at build time. A mint indexed since the last deploy has no page of # its own until the next one; the 404 fallback resolves it against the live API, so it is # readable and reviewable in the meantime. That was already true between nightly builds. # # There is deliberately no [Install] section — this belongs to a deploy, not to a boot. [Unit] Description=Rebuild the cashumints.space static site # Every page's data comes from the API over loopback, so the API has to be up. # Requires= rather than Wants=: a dead API should abort the build, not replace a good # site with an empty one. Requires=cashumints.service After=cashumints.service network-online.target Wants=network-online.target # Carry a failure off the machine. `%n` is this unit's own name, so the alert says # which one died. cashumints-alert@.service writes to the journal at ERROR always and # curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set. OnFailure=cashumints-alert@%n.service [Service] Type=oneshot User=cashumints Group=cashumints WorkingDirectory=/home/cashumints/CashuMints.space # Where the published copy lands. Shared with the API and the site server, and created # by systemd with this unit's ownership if it is not there yet. StateDirectory=cashumints Environment=NODE_ENV=production # Where the build reaches the API. Must match PORT= in cashumints.service. Environment=API_URL=http://127.0.0.1:8788 Environment=SITE_URL=https://cashumints.space # Browser-facing origin. Empty means same origin: islands fetch /api/... and nginx # forwards it. Set this only if the API ever moves to its own hostname. Declared here # even though it is empty, because systemd's environment wins over .env — so what a # production build emits cannot drift with an edit to that file. Environment=PUBLIC_API_URL= # After= orders the start; it does not wait for the port to accept connections. At boot # the API is still opening its database and probing, so block until it reports healthy # rather than letting the first fetch die on ECONNREFUSED. /api/health answers 503 until # it is genuinely ready, and curl -f treats that as a failure, so the loop keeps waiting. ExecStartPre=/usr/bin/timeout 90 /bin/sh -c 'until curl -sf -o /dev/null http://127.0.0.1:8788/api/health; do sleep 1; done' # Then: does the API actually have an index to build a site out of? # # Health answering 200 says the process is up and its last backfill read something. It # does not say how many mints are in the table, and those are different questions — the # year of ~31-event backfills had a healthy API serving a real, complete, correct list of # eight mints. A build against that succeeds, prerenders eight cards, and rsync happily # replaces fifty-five with eight. # # So count the list before spending twenty minutes building from it. Below the floor # this exits non-zero, systemd abandons the unit at ExecStartPre, and — because publishing # is ExecStartPost, after the build — the previously published site is never touched. The # site stays exactly as it was and the OnFailure alert says why. # # Counted by the `"host":` key, one per item, rather than by counting `{`: the list # payload carries a nested object per mint (its NUT capability switches), so brace # counting would report roughly double. No jq: it is not installed on this host and a # build gate should not add a dependency to run. # # `Q` is a double-quote character, built with printf rather than written literally, # because this whole command is already inside systemd's single quotes and a quote of # either kind in the grep pattern would end the argument early. # # A curl that fails for any reason leaves `n` empty, `$${n:-0}` reads that as zero, and # zero is below every floor — so an API that fell over between the health check above and # this line refuses the build rather than sailing through it. Environment=MIN_MINTS_FOR_BUILD=20 ExecStartPre=/bin/sh -c 'Q=$$(printf "\\042"); \ n=$$(curl -sf --max-time 30 http://127.0.0.1:8788/api/mints | grep -o "$${Q}host$${Q}:" | wc -l); \ if [ "$${n:-0}" -lt "$$MIN_MINTS_FOR_BUILD" ]; then \ printf "<3>%s\\n" "refusing to build: /api/mints returned $${n:-0} mints, floor is $$MIN_MINTS_FOR_BUILD. Previous site left untouched."; \ exit 1; \ fi; \ printf "%s\\n" "build gate: $$n mints, floor $$MIN_MINTS_FOR_BUILD"' # Check `which pnpm` on the host: a corepack or pnpm-home install sits outside /usr/bin, # and systemd's PATH does not include it. ExecStart=/usr/bin/pnpm build # Publish, as a separate step from building. # # `astro build` empties dist before it writes, so the site server cannot read dist # directly — a rebuild would be a minute of 404s. It serves this copy instead, and the # copy is only touched once a build has succeeded: a failed build leaves the previous # site up rather than replacing it with a half-written one, which is the same reason # Requires=cashumints.service is above and the same reason the mint-count gate is an # ExecStartPre rather than a check after the fact. # # --delay-updates stages the changed files and renames them in at the end, so the window # where the tree is a mix of two builds is a rename rather than a whole transfer, and # --delete-after keeps removals from landing before their replacements. Unchanged files # — every hashed asset and card, which is nearly all of it — are not touched at all. ExecStartPost=/usr/bin/rsync -a --delete-after --delay-updates web/dist/ /var/lib/cashumints/web/ # ~200 prerendered pages plus a card per mint. Minutes, not seconds, on a small VPS, and # TimeoutStartSec is what bounds a Type=oneshot. TimeoutStartSec=1800 # A build should not starve the API it is reading from. Nice=10 # The site server runs as cashumints and reads its own files, so this no longer has to # be world-readable — it was 0022 for nginx, back when nginx opened the files as # www-data. Kept at 0022 anyway: rsync preserves these modes into the published copy, # and a readable static site is easier to inspect than one that needs sudo. UMask=0022 NoNewPrivileges=true PrivateTmp=true PrivateDevices=true # ProtectHome is deliberately absent, unlike in cashumints.service: this unit writes # inside /home/cashumints — web/dist, web/public/og, web/src/generated and the pnpm # store are all under it. ProtectSystem=full ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6