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:
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user