Files
CashuMints.space/deploy/cashumints-web.service
michilisandClaude Opus 5 24fe2003b6 Drop the nightly rebuild timer.
The timer was load-bearing while the mint list was a build-time snapshot: a
rebuild was the only way a new mint, a new review count or a changed status ever
reached /mints. The list hydrates now, so all three arrive within a second of
load, in every language, and rebuilding 2,000 pages at 03:30 to refresh numbers
that refresh themselves is twenty minutes of CPU for nothing.

cashumints-web.service stays exactly as it is — it is the deploy-time publish
step, and now the only thing that starts it is a deploy. A build still produces
what only a build can: the prerendered HTML a crawler reads, a social card per
mint, the sitemap and hreflang set, and a /mint/{host} page for every mint known
at build time.

The one thing that gets staler is that last item. A mint indexed since the last
deploy has no prerendered page: /mint/newhost is a 404, whose resolver looks the
address up against the live API and renders it — readable, reviewable, noindex
until a deploy gives it a real page. That was already true between nightly
builds; this only lengthens the window.

README documents the one-time host commands to remove the installed timer, and
gains a "Live lists" section describing what replaced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:29:09 +02:00

142 lines
7.5 KiB
Desktop File

# /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