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>
161 lines
6.3 KiB
Nginx Configuration File
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.
|
|
}
|