# The console's own server. It sees only requests the gateway has already authenticated # and forwarded, so there is no auth here — and there must not be, or there would be two # places deciding who may read the console. server { listen 80; server_name _; root /usr/share/nginx/html; # The gateway proxies /admin/* through untouched, so the paths arriving here carry that # prefix. Vite is built with base=/admin/, so the asset URLs in index.html match. location /admin/assets/ { alias /usr/share/nginx/html/assets/; # Hashed filenames: a changed file is a changed name, so this can be cached hard # and a deployment is picked up the moment the shell is re-fetched. expires 1y; add_header Cache-Control "public, immutable"; access_log off; } # Canonicalise the console root even though the gateway normally performs this # redirect before proxying. It keeps the static container correct if it is ever # reached directly during diagnosis or through a future internal proxy change. location = /admin { return 308 /admin/; } # Every other /admin path is a console route — /admin/logins, /admin/devices/tv-1, a # deep link, a refresh, the Back button — and none of them exist as files. They all # answer with the shell, which is what makes client-side routing work on a reload. location /admin { try_files $uri @console_shell; # The shell must never be cached: it carries the asset hashes, so a stale copy # points at JavaScript a deployment has already replaced. The gateway sets this too # on the way past; it is stated in both places because either alone would be enough # to get wrong. add_header Cache-Control "no-store" always; } # `try_files ... /index.html` would internally re-match the fallback against the # catch-all `location /` below, which deliberately returns 404. A named location # retains this server's static root and serves the shell without exposing /index.html # as a second public route. location @console_shell { try_files /index.html =404; add_header Cache-Control "no-store" always; } # A liveness probe for docker-compose. Deliberately outside /admin so it cannot be # confused with a console route, and it answers without touching the filesystem. location = /healthz { access_log off; default_type text/plain; return 200 "ok\n"; } # Nothing else is served. The console is the only thing in this image. location / { return 404; } # The console fetches nothing from anywhere: no CDN, no fonts, no analytics. Saying so # in a policy means a dependency that tried to would fail loudly during development # rather than quietly phone home from an operator's browser. # # 'unsafe-inline' for styles is Vite's injected critical CSS; connect-src 'self' covers # the API and the notification stream, both of which are same-origin by construction. add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "no-referrer" always; gzip on; gzip_types text/css application/javascript application/json image/svg+xml; gzip_min_length 1024; }