Gunnar

nordisk/holger-ui

deploy/nginx.conf

# 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;
    }
}