Serve the prerendered site from Node instead of nginx root.

Avoids www-data traversing the cashumints tree and keeps rebuilds from blanking a live root.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-25 06:27:27 +02:00
co-authored by Cursor
parent 301679d340
commit 79a115be38
6 changed files with 875 additions and 125 deletions
+323 -125
View File
@@ -210,7 +210,7 @@ ships at `/fonts/OFL.txt`.
They sit in `src/assets/` rather than `public/` so the build fingerprints them into
`/_astro/`, which is what makes `Cache-Control: immutable` honest and puts them under a
cache rule the nginx config already has. Unhashed and uncached they are re-fetched on
cache rule `web/server.mjs` already has. Unhashed and uncached they are re-fetched on
every client-side navigation — the router re-inserts their preload links on each swap —
and the typefaces visibly reload from page to page.
@@ -305,7 +305,8 @@ hashed into its filename (`mint.example.com.a1b2c3d4e5.png`) and
is also the cache-busting story — link-preview scrapers cache an `og:image` by URL, so
a mint whose rating moved or that went offline gets a new URL on the next scheduled
rebuild, while the stable-named `default.png` (brand plus network stats, used by every
non-mint page) is served with a one-hour cache instead (see the nginx block below).
non-mint page) is served with a one-hour cache instead — the one exception in
`cacheControl()` in `web/server.mjs`.
The images are one English render shared by every locale; titles, descriptions and alt
text translate per page. Relative times stay out of the PNGs on purpose — a "3d ago"
@@ -601,8 +602,8 @@ so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still ove
`PUBLIC_API_URL` deliberately does **not** fall back to `API_URL`: that is a build-machine
address, and a visitor's browser cannot reach `127.0.0.1`. Left empty, icon `src`
attributes and island fetches are same-origin paths (`/icons/...`, `/api/...`), which the
dev server proxies to `API_URL` and nginx forwards in production — see "Static site behind
nginx" below.
dev server proxies to `API_URL` and nginx forwards in production — see "nginx" under
Deployment below.
Set it only when the API answers on its own origin:
@@ -753,10 +754,11 @@ and capped at two with every hop re-checked, a 256KB response cap, and a per-IP
`INDEX_RATE_LIMIT` an hour with a clean `429`. Two simultaneous submissions of one address
share a single probe. `pnpm --filter ./api test:index` covers all of it without a network.
The limit needs to be able to tell two visitors apart, so behind the nginx block below add
`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` to `location /api/`. The
last entry of that header is the one used, because it is the one the proxy vouched for;
the header is ignored entirely when the peer is not loopback.
The limit needs to be able to tell two visitors apart, which is what `include proxy_params;`
on every proxied location in the nginx block below is for — it is what sets
`X-Forwarded-For`, and without it every submission arrives from the loopback peer and
shares one budget. The last entry of that header is the one used, because it is the one
the proxy vouched for; the header is ignored entirely when the peer is not loopback.
### Ranking
@@ -780,193 +782,368 @@ literal specified behaviour. `shared/src/score.ts` carries the arithmetic, and
## Deployment
### API as a systemd unit
Three units and an nginx block. Everything this project runs listens on loopback and
runs as the same unprivileged user; nginx terminates TLS and proxies to it, and opens no
file belonging to the project.
```
:443 nginx ──► 127.0.0.1:8789 cashumints-site.service the prerendered site
└─► 127.0.0.1:8788 cashumints.service /api/* and /icons/*
cashumints-web.service oneshot: build, then publish to
/var/lib/cashumints/web
```
nginx used to point a `root` at `web/dist` instead of proxying. That is the arrangement
to avoid, and the reason is worth stating because the failure mode is so quiet: nginx
runs as `www-data`, everything here runs as `cashumints`, and serving files across that
boundary requires every directory from `/` down to `dist` to be traversable by a user
with no other business in the tree. A home directory left at its default `0700` breaks
the whole site, and `try_files` reports a permission error as a plain miss — so the
symptom is a blanket 404, or an internal-redirect loop that ends in a 500, with the
cause named nowhere. Proxying removes the boundary: the process that serves the files
is the one that built them.
### Node
The API and the site server both run TypeScript and ESM directly, with no build step, so
**systemd's node must be 22.18 or newer** — that is the release where native type
stripping stopped needing a flag. This is not the same question as `node -v` in your
shell: a version manager puts its node on the interactive `PATH` only, while systemd
resolves the absolute path in `ExecStart`. Check the one that matters:
```bash
/usr/bin/node --version
```
On Node 20 the API exits immediately with `ERR_UNKNOWN_FILE_EXTENSION` for `.ts` and
restarts forever. Install Node system-wide rather than pointing `ExecStart` at a version
manager's path, which breaks at the next upgrade and is invisible to `ProtectHome`.
### The API
```ini
# /etc/systemd/system/cashumints-api.service
# /etc/systemd/system/cashumints.service
[Unit]
Description=cashumints.space indexer and API
After=network-online.target
Wants=network-online.target
After=network-online.target
# Stop after five failures in a minute rather than restarting forever: a process that
# cannot start will not start on the 4000th attempt either, and `failed` in
# `systemctl status` is a louder signal than a journal scrolling past. Both keys belong
# to [Unit] — under [Service] systemd only warns and ignores them.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
User=cashumints
WorkingDirectory=/srv/cashumints/api
ExecStart=/usr/bin/node src/index.ts
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/api
# StateDirectory creates /var/lib/cashumints with the service user's ownership.
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=PORT=8787
Environment=PORT=8788
Environment=DB_PATH=/var/lib/cashumints/cashumints.db
Environment=ICON_DIR=/var/lib/cashumints/icons
# For Postgres, replace DB_PATH with DATABASE_URL and add After=postgresql.service above.
# For Postgres, replace DB_PATH with DATABASE_URL and add After=postgresql.service.
# Keep ICON_DIR either way: cached icons are files, not rows.
Restart=always
RestartSec=5
ExecStart=/usr/bin/node --env-file-if-exists=../.env src/index.ts
Restart=on-failure
RestartSec=5s
# The process finishes its in-flight probe batch and closes the database on SIGTERM.
KillSignal=SIGTERM
TimeoutStopSec=30
TimeoutStopSec=30s
UMask=0027
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now cashumints-api
`Environment=` in the unit and `--env-file-if-exists=../.env` are not interchangeable.
Anything set in the unit wins, so a value that must not drift with an edit to `.env`
belongs in the unit; a secret belongs in `.env`, which is not world-readable.
### The site server
`web/server.mjs` serves the prerendered tree and nothing else — no template, no database
handle, no route table. It is a few hundred lines of `node:http` with no dependencies,
and `pnpm --filter ./web test` covers what it has to get right: a miss answers 404
rather than 200 with a 404-shaped body, a miss under a locale stays in that locale,
hashed assets are immutable and markup is not, and nothing outside the root is readable
however the path is spelled.
Two behaviours are worth knowing about because they are load-bearing:
- **It refuses to start on a root with no `index.html`.** The failure that prevents is a
process that starts cleanly, answers every request with 404 and looks healthy to
anything watching the port.
- **It does not serve `web/dist`.** `astro build` empties `dist` before it writes, so
serving it directly means a rebuild takes the site down for the length of the build.
It serves the published copy at `WEB_ROOT` instead.
```ini
# /etc/systemd/system/cashumints-site.service
[Unit]
Description=cashumints.space static site server
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
# Not Requires=cashumints.service: the pages are prerendered, so the site keeps serving
# a correct-as-of-last-build copy while the API is down. Only the islands go quiet.
[Service]
Type=simple
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/web
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=SITE_PORT=8789
Environment=SITE_HOST=127.0.0.1
Environment=WEB_ROOT=/var/lib/cashumints/web
ExecStart=/usr/bin/node server.mjs
Restart=on-failure
RestartSec=5s
KillSignal=SIGTERM
TimeoutStopSec=15s
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
# Read-only rather than absent: server.mjs itself lives under /home/cashumints.
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
```
### Static site behind nginx
### nginx
```nginx
server {
listen 443 ssl http2;
server_name cashumints.space;
root /srv/cashumints/web;
index index.html;
# Astro emits directory-style routes, so try the directory index before 404.
location / {
try_files $uri $uri/ $uri.html /404.html;
}
# A miss under a language prefix answers in that language. Without these two blocks
# every 404 is the English one, including the ones a Spanish reader reaches by
# following a Spanish link, and the language control on it would take them to a page
# they were not on.
location /es/ {
try_files $uri $uri/ $uri.html /es/404/index.html;
}
location /nl/ {
try_files $uri $uri/ $uri.html /nl/404/index.html;
}
# Fingerprinted assets are immutable. This covers the CSS, the island bundles and the
# webfonts, which are all emitted here with a content hash in the name. Serving the
# fonts without it is visible, not theoretical: the view transition router re-inserts
# the font preload links on every navigation, so an uncached font is re-fetched on
# each page change and the typefaces flicker as they reload.
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# The favicons and the manifest. Not fingerprinted, since their names are referenced
# from outside the site, so a week with revalidation rather than a year of immutability.
location ~* ^/(og\.png|favicon\.(ico|svg)|apple-touch-icon\.png|icon-\d+\.png|site\.webmanifest)$ {
expires 7d;
add_header Cache-Control "public";
}
# The generated social cards (`pnpm og`). The default card keeps a stable name and
# changes with the site's stats, so it gets an hour; every per-mint card carries a
# content hash in its filename and a stale hash is never referenced again, so those
# are immutable. (The hash is also what actually refreshes link previews: the big
# scrapers cache an og:image by URL and ignore these headers.)
location = /og/default.png {
add_header Cache-Control "public, max-age=3600";
}
location /og/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Same-origin API and icons, matching the default empty PUBLIC_API_URL. Drop these two
# blocks only if you build with PUBLIC_API_URL pointing at a separate API host.
location /api/ {
proxy_pass http://127.0.0.1:8787;
# POST /api/index is rate limited per address, and without this every visitor
# arrives as 127.0.0.1 and shares one budget.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /icons/ {
proxy_pass http://127.0.0.1:8787;
expires 1d;
}
error_page 404 /404.html;
}
```
Add a `location` block per language when you add one. The 404 is also the client-side
fallback for a mint discovered since the last build, and it reads the locale off its own
URL, so `/es/mint/some-new-mint` resolves that mint and renders its summary in Spanish.
### API behind nginx, with a micro-cache
`/api/mints` and `/api/stats` change at most every few minutes but can be requested by
every visitor at once. A short micro-cache absorbs that without making the data stale.
`/api/health` is deliberately excluded: it is the endpoint you page on.
The whole public surface. Two upstreams, no `root`, no `try_files`, no `error_page`: a
miss is the site server's 404 page, in the right language and with a 404 status, and an
nginx error page here would replace it with a blank one and hide which upstream failed.
```nginx
proxy_cache_path /var/cache/nginx/cashumints levels=1:2 keys_zone=cashumints:10m
max_size=256m inactive=10m use_temp_path=off;
# Keep a few connections open to each upstream rather than reconnecting per request.
# Both processes hold idle sockets longer than nginx does, so nginx is always the side
# that closes — the reverse of that race is what produces sporadic 502s under load.
upstream cashumints_site { server 127.0.0.1:8789; keepalive 16; }
upstream cashumints_api { server 127.0.0.1:8788; keepalive 8; }
server {
listen 443 ssl http2;
server_name api.cashumints.space;
listen [::]:443 ssl http2;
server_name cashumints.space;
location /api/health {
proxy_pass http://127.0.0.1:8787;
proxy_cache off;
add_header Cache-Control "no-store" always;
ssl_certificate /etc/letsencrypt/live/cashumints.space/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cashumints.space/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Set here because this is the only part of the stack that knows a request arrived
# over TLS. Note that add_header replaces rather than merges: any location declaring
# its own add_header drops this one and has to restate it.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# The upstreams send no Content-Encoding, so compression is nginx's to do.
# gzip_proxied any is required — without it nginx will not compress a proxied response
# at all. The home page goes out at a fifth of its size.
gzip on;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json
application/manifest+json application/xml image/svg+xml;
# Nothing here accepts an upload; the API's largest body is an 8 KB JSON submission.
client_max_body_size 16k;
# Both upstreams are a process on this machine. A slow response is a bug, not a
# network condition, and failing fast beats holding a worker for a minute.
proxy_connect_timeout 2s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
# HTTP/1.1 with an empty Connection header is what makes the keepalive pools work;
# the default 1.0 opens a new socket per request.
proxy_http_version 1.1;
proxy_set_header Connection "";
# proxy_params (Debian) sets Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto.
# That include is load-bearing: the API's rate limiter reads the last hop of
# X-Forwarded-For to tell two visitors apart, and without it every request arrives from
# the loopback peer and shares one bucket. Do not also set X-Forwarded-For beside the
# include — declaring it twice is what produces nginx's proxy_headers_hash warning.
# Cache-Control comes from server.mjs: a year and immutable for anything with a
# content hash in its name, revalidate-every-time for markup. Nothing to restate here.
location / {
proxy_pass http://cashumints_site;
include proxy_params;
}
location ~ ^/api/(mints|stats) {
proxy_pass http://127.0.0.1:8787;
# Health must always reflect the live process. It is the endpoint you page on.
location = /api/health {
proxy_pass http://cashumints_api;
proxy_cache off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Cache-Control "no-store" always;
include proxy_params;
}
# /api/mints and /api/stats change every few minutes but can be asked for by every
# visitor at once. A micro-cache absorbs that without making the data stale.
location ~ ^/api/(mints|stats)(?:/|$|\?) {
proxy_pass http://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 30s;
proxy_cache_valid 404 10s;
# Serve the previous response while one request refreshes it, so a slow
# backend never becomes a slow page.
# Serve the previous response while one request refreshes it, so a slow backend
# never becomes a slow page.
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Cache-Status $upstream_cache_status always;
include proxy_params;
}
location /api/ {
proxy_pass http://cashumints_api;
include proxy_params;
}
location /icons/ {
proxy_pass http://127.0.0.1:8787;
proxy_pass http://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 1d;
include proxy_params;
}
}
server {
listen 80;
listen [::]:80;
server_name cashumints.space;
location /.well-known/acme-challenge/ { root /var/www/html; }
location / { return 301 https://cashumints.space$request_uri; }
}
```
`/api/` and `/icons/` are proxied same-origin because `PUBLIC_API_URL` is empty, which is
the default. Drop those blocks only if you build with it pointing at a separate API host.
nginx 1.25 and later want `http2 on;` on its own line and warn about the `listen … http2`
form above; Debian 12 ships 1.22, where the newer form is an unknown directive. The form
above is the one that works on both.
### Rebuilds
Mint pages are prerendered, so new mints and new review counts appear at the next build.
A nightly rebuild is enough; the site stays correct in between because the islands refresh
status and reviews at runtime, and an unbuilt mint still resolves through the client-side
fallback on the 404 page.
A nightly rebuild is enough; the site stays correct in between because the islands
refresh status and reviews at runtime, and an unbuilt mint still resolves through the
client-side fallback on the 404 page.
Publishing is a separate step from building, and the separation is the point: the copy
the site server reads is only touched once a build has succeeded, so a failed build
leaves the previous site up rather than replacing it with a half-written one.
```ini
# /etc/systemd/system/cashumints-build.service
# /etc/systemd/system/cashumints-web.service
[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
[Service]
Type=oneshot
User=cashumints
WorkingDirectory=/srv/cashumints
Environment=API_URL=http://127.0.0.1:8787
Environment=PUBLIC_API_URL=https://api.cashumints.space
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space
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. Declared 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'
# 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
ExecStartPost=/usr/bin/rsync -a --delete web/dist/ /srv/cashumints/web/
# --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/
# ~500 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 nightly rebuild should not starve the API it is reading from.
Nice=10
UMask=0022
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
# ProtectHome is deliberately absent, unlike in the other two units: this one 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
```
There is deliberately no `[Install]` section: a rebuild should be scheduled, not fired on
every boot.
```ini
# /etc/systemd/system/cashumints-build.timer
# /etc/systemd/system/cashumints-web.timer
[Unit]
Description=Nightly cashumints.space rebuild
@@ -978,10 +1155,31 @@ Persistent=true
WantedBy=timers.target
```
### First deploy
Order matters once: the site server refuses to start against a root that has no
`index.html`, so the build has to publish before it comes up.
```bash
sudo systemctl enable --now cashumints-build.timer
/usr/bin/node --version # 22.18 or newer, or the API will not run
sudo systemctl enable --now cashumints # API first: the build reads from it
sudo systemctl start cashumints-web # build, then publish to /var/lib/cashumints/web
sudo systemctl enable --now cashumints-site # now it has something to serve
sudo systemctl enable --now cashumints-web.timer
sudo nginx -t && sudo systemctl reload nginx
```
```bash
curl -sI https://cashumints.space | head -1 # 200
curl -sI https://cashumints.space/nope | head -1 # 404, not 200
curl -s https://cashumints.space/api/health # status ok
```
A 502 on `/` means the site server is down or was never started; a 502 on `/api/` means
the API is. They fail independently, which is the other thing proxying buys: the
prerendered site keeps serving while the API is restarting.
## Licence
See `LICENSE`.