# Production serving of the holger console — one origin, two jobs:
#
# 1. Static SPA: the trunk release build (dist/), with the index.html
# fallback the router REQUIRES (every view is a URL).
# 2. gRPC-web: /grpc/* stripped and proxied to holger's gRPC port,
# where the tonic-web door terminates the protocol.
#
# Same-origin by construction — no CORS anywhere, exactly the posture the
# server ships with. TLS is the outer layer's job (tailscale serve / the
# site's LB); this listener stays loopback HTTP.
server {
# Loopback ONLY. With host networking a bare `listen 8480` binds every
# interface, so the console answered on the LAN address over plain HTTP —
# past tailscale serve and its TLS. The tailnet proxy targets
# http://127.0.0.1:8480, so loopback is the whole surface it needs.
listen 127.0.0.1:8480;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ── Security headers ─────────────────────────────────────────────
# The CSP is generated per release by deploy/publish.sh: trunk emits ONE
# inline module-bootstrap whose sha256 changes with every build, so the
# script computes it and replaces the marker below. connect-src 'self' is
# the line that matters for the sessionStorage token threat model — an
# injected script cannot exfiltrate to a foreign origin. Deployments using
# OIDC must append the IdP origin to connect-src (publish.sh's IDP_ORIGIN).
# __CSP__
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
# ── gRPC-web door ────────────────────────────────────────────────
# Trailing slashes strip the /grpc prefix on forward. HTTP/1.1 to the
# upstream is correct: tonic's serve tail accepts http1 for exactly
# this hop. Request buffering off so a 1 GiB artifact upload streams
# instead of spooling to disk; body cap mirrors the server's own.
location /grpc/ {
proxy_pass http://127.0.0.1:50051/;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
client_max_body_size 1g;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# ── Hashed assets: immutable ─────────────────────────────────────
# trunk embeds a content hash in every filename, so these can be
# cached forever; a new release is new URLs.
location ~* \.(wasm|js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
# nginx: a location with ANY add_header inherits NONE from the server
# level — the security headers must be repeated here.
# __CSP__
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
try_files $uri =404;
}
# ── SPA fallback ─────────────────────────────────────────────────
# /repo/bundles, /artifact?…, /search?… are all client-side routes:
# unknown paths get index.html (never cached — it names the hashes).
location / {
add_header Cache-Control "no-cache";
# Same inheritance rule as above.
# __CSP__
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
try_files $uri /index.html;
}
}