Files
michilisandClaude Opus 5 65307ba278 Compile the API instead of running its TypeScript in production.
The unit's ExecStart named src/index.ts, so every start depended on the host
having Node 22.18 or newer for native type stripping. A deploy onto a host with
Node 20 met ERR_UNKNOWN_FILE_EXTENSION, exited in under a second, and was
restarted 464 times over fifteen hours with nothing anywhere going red.

api/tsconfig.json now emits to api/dist. The source keeps its explicit .ts import
specifiers, which is what makes `node --watch src/index.ts` work in development;
rewriteRelativeImportExtensions turns them into .js on the way out, so what runs
in production is ordinary ESM that any Node from 20.18 up will start.

`pnpm build` builds shared, then api, then web. `pnpm dev` is unchanged.

deploy/ is tracked rather than ignored: the unit files are the thing an operator
copies to /etc/systemd/system, and the alert unit added next has to live
somewhere a deploy can find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:58:53 +02:00

161 lines
6.3 KiB
Nginx Configuration File

# /etc/nginx/sites-available/cashumints.space
#
# nginx terminates TLS and proxies. It opens no file belonging to this project — not the
# built site, not an icon — and that is deliberate.
#
# It used to point a `root` at web/dist. Because nginx runs as www-data and everything
# this project owns runs as cashumints, that arrangement required every directory from /
# down to dist to be traversable by a user with no other business in the tree. A home
# directory at its default 0700 anywhere in that chain broke the entire site, and it
# broke it invisibly: `try_files` treats a permission error as a plain miss, so the
# symptom was a blanket 404, or an internal-redirect loop that ended in a 500 with the
# real cause named nowhere.
#
# Two upstreams now, both on loopback, both owned by the same user that built what they
# serve:
#
# 127.0.0.1:8789 cashumints-site.service the prerendered site
# 127.0.0.1:8788 cashumints.service /api/* and /icons/*
#
# Routing that used to live here lives with the thing that owns it. The locale 404 rule
# in particular was a hand-maintained alternation of 23 codes in a file that is not in
# the repository; adding a language meant remembering to edit it, and forgetting was
# silent. web/server.mjs resolves those from the built tree.
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 and there is no window where it reuses a socket the upstream just dropped
# — 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 80;
listen [::]:80;
server_name cashumints.space;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://cashumints.space$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name cashumints.space;
# nginx 1.25 and later want `http2 on;` on its own line and warn about the form above.
# Left as is because it is the form that works on both, and Debian 12 ships 1.22.
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 rather than upstream: this is the only part of the stack that knows a
# request arrived over TLS. Add `preload` only once you are content never to serve
# this name over plain HTTP again.
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 refuses to compress a proxied response at all.
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,
# and rejecting the oversized ones at the edge keeps them off the Node process.
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 above
# work; the default 1.0 opens a new socket per request.
proxy_http_version 1.1;
proxy_set_header Connection "";
# Every proxied location includes Debian's /etc/nginx/proxy_params, which sets Host,
# X-Real-IP, X-Forwarded-For and X-Forwarded-Proto. That include is load-bearing, not
# decorative: 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. On a distro that ships no proxy_params, set those four by hand.
# Do not also set X-Forwarded-For alongside the include — declaring it twice is what
# produces nginx's "could not build optimal proxy_headers_hash" warning.
# The prerendered site. Cache-Control comes from server.mjs — a year and immutable for
# anything with a content hash in its name, revalidate-every-time for markup — so
# there is nothing to restate here.
location / {
proxy_pass http://cashumints_site;
include proxy_params;
}
# Health must always reflect the live process.
location = /api/health {
proxy_pass http://cashumints_api;
proxy_cache off;
# add_header replaces rather than merges: declaring one here drops every add_header
# inherited from the server block, so HSTS has to be restated alongside it.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Cache-Control "no-store" always;
include proxy_params;
}
# Briefly cache the read-heavy endpoints.
location ~ ^/api/(mints|stats)(?:/|$|\?) {
proxy_pass http://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 30s;
proxy_cache_valid 404 10s;
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
# Restated for the same reason as in /api/health above.
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://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 1d;
include proxy_params;
}
# No error_page and no try_files. A miss is the site server's 404 page, in the right
# language and with a 404 status; an nginx error page here would replace it with a
# blank one and hide which upstream failed.
}