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