# Mirrors the "%(asctime)s [%(name)s] %(levelname)s %(message)s" format used # by the Connect backend's Python loggers, so nginx and backend log lines # look consistent in `docker logs`. log_format connect '$time_iso8601 [nginx] INFO $remote_addr - "$request" $status $body_bytes_sent'; # Party guests may arrive through a TLS-terminating proxy in front of this # one; keep the scheme it reports so connect can mark the guest cookie # Secure. Without such a proxy, this nginx's own scheme is the answer. map $http_x_forwarded_proto $party_forwarded_proto { default $http_x_forwarded_proto; "" $scheme; } server { listen ${WEB_PORT}; listen [::]:${WEB_PORT}; sendfile on; default_type application/octet-stream; # Gated behind DEBUG=true (see start.sh) — access logs are noisy and # usually not needed outside of troubleshooting. ${NGINX_ACCESS_LOG} gzip on; gzip_http_version 1.1; gzip_disable "MSIE [1-6]\."; gzip_min_length 256; gzip_vary on; gzip_proxied expired no-cache no-store private auth; gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript; gzip_comp_level 9; location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html =404; } # index.html isn't content-hashed, so nginx sends no Cache-Control for it # by default and the browser falls back to its own heuristic — meaning a # stale copy can outlive a redeploy for a while, referencing asset hashes # that no longer exist. A normal reload only re-fetches it if the browser # bothers to revalidate — force that every time (still cheap: a 304 when # unchanged). location = /index.html { add_header Cache-Control "no-cache"; root /usr/share/nginx/html; } location = /settings.js { alias /etc/nginx/conf.d/settings.js; add_header Cache-Control "no-store"; } # Everything the connect backend exposes except the three proxy-shaped # routes below (see connect/main.py's router list) — /config, /health, # /play, /discover, /volume, /join, /claim, /pairing/*, /lyrics/*, /events, # /status, ... all fall through to this one location. The trailing slash # on both the location and proxy_pass URL is what makes nginx strip the # "/api/" prefix down to the backend root (see connect/routes/proxy.py's # own "nginx strips /api/" comment, which assumes exactly this). location /api/ { proxy_pass http://127.0.0.1:${PORT}/; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Connect-Token "${CONNECT_TOKEN}"; # /events and /visualizer are long-lived SSE streams (see # routes/stream.py, routes/events.py) — without proxy_http_version 1.1, # nginx defaults to HTTP/1.0 towards the backend, which has no chunked # transfer encoding, so for a response with no known Content-Length # (a live stream) nginx ends up batching it into internal buffers # instead of forwarding each chunk as it arrives. The backend already # sends X-Accel-Buffering: no for exactly this, but that alone wasn't # reliably disabling it in practice — this is the actual fix, matching # /rest/'s proxy_buffering off below. proxy_http_version 1.1; proxy_buffering off; } # Subsonic proxy — matches connect/routes/proxy.py's /rest/{path} route # directly (unprefixed), same as Electron's direct connection would hit. location /rest/ { proxy_pass http://127.0.0.1:${PORT}/rest/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Connect-Token "${CONNECT_TOKEN}"; proxy_buffering off; proxy_read_timeout 300s; } location /auth/ { proxy_pass http://127.0.0.1:${PORT}/auth/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Connect-Token "${CONNECT_TOKEN}"; } # Party mode's guest page and API (connect/routes/party.py). Its own # prefix so a reverse proxy can let exactly this past its login, and the # one location here that never injects X-Connect-Token: everything under # it authenticates with the guest's own cookie instead. location = /party { proxy_pass http://127.0.0.1:${PORT}/party; proxy_set_header Host $host; } location /party/ { proxy_pass http://127.0.0.1:${PORT}/party/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $party_forwarded_proto; # The token injected for /api/ must not ride along on a request that # carries one of its own by accident - cleared rather than left out. proxy_set_header X-Connect-Token ""; # Same SSE reasoning as /api/ above (/party/api/events). proxy_http_version 1.1; proxy_buffering off; } location /stream { proxy_pass http://127.0.0.1:${PORT}/stream; proxy_set_header X-Connect-Token "${CONNECT_TOKEN}"; # Same reasoning as /api/'s identical two lines above: without # proxy_http_version 1.1, nginx speaks HTTP/1.0 to the backend, which # has no chunked transfer encoding, so a response with no known # Content-Length (this live ffmpeg-fed audio stream) gets buffered # internally instead of forwarded chunk-by-chunk as it arrives — the # backend's own X-Accel-Buffering: no header alone isn't reliably # enough to prevent that. Matters even more here than for /api/: a cast # device pulling this in real time needs the data flowing continuously, # not held back by nginx and then released in an unpredictable burst. proxy_http_version 1.1; proxy_buffering off; } }