Nginx configuration¶
The frontend uses Nginx as a reverse proxy and static file server. The configuration lives in
frontend/nginx.conf.template.
Architecture¶
flowchart LR
Browser --> Nginx
Nginx -->|"/api/*"| Backend["Backend :443"]
Nginx -->|"/grafana/*"| Grafana["Grafana :3000"]
Nginx -->|"static files"| Static["Static files"]
Nginx serves three purposes: static file server for the Svelte frontend build, reverse proxy for API requests to the
backend, and reverse proxy for Grafana (when the observability Docker Compose profile is active).
Configuration breakdown¶
Server block¶
server {
listen 5001 ssl;
server_name _;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
root /usr/share/nginx/html;
index index.html;
| Directive | Purpose |
|---|---|
listen 5001 |
Internal container port (mapped via Docker Compose) |
server_name _ |
Catch-all server name |
root |
Static files from Svelte build |
Compression¶
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json application/x-font-ttf font/opentype image/svg+xml image/x-icon;
gzip_disable "msie6";
Gzip compression reduces bandwidth for text-based assets. Binary files (images, fonts) are excluded as they're already compressed.
| Directive | Value | Purpose |
|---|---|---|
gzip on |
Enable gzip compression globally | |
gzip_vary |
on |
Add Vary: Accept-Encoding header so caches store both versions |
gzip_min_length |
1024 |
Skip compression for responses under 1 KB (overhead not worth it) |
gzip_types |
text/css, application/json, ... | MIME types to compress (text-based only, not images/video) |
gzip_disable |
"msie6" |
Disable for IE6 which mishandles gzipped responses |
API proxy¶
location /api/ {
proxy_pass ${BACKEND_URL};
proxy_ssl_verify off;
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 $scheme;
# Forward cookies
proxy_pass_request_headers on;
proxy_set_header Cookie $http_cookie;
| Directive | Purpose |
|---|---|
proxy_pass ${BACKEND_URL} |
Forward requests to the backend; the variable is replaced by envsubst at container start |
proxy_ssl_verify off |
Skip TLS certificate verification — safe because this is container-to-container traffic on an internal Docker network. Never use this when proxying to external or untrusted backends — it disables certificate validation entirely, making the connection vulnerable to MITM attacks |
proxy_set_header Host $host |
Forward the original Host header so the backend sees the client's requested hostname |
proxy_set_header X-Real-IP |
Pass the client's real IP address for rate limiting and audit logging |
proxy_set_header X-Forwarded-For |
Append client IP to the proxy chain header (standard for multi-layer proxies) |
proxy_set_header X-Forwarded-Proto |
Preserve the original protocol (http/https) so the backend can build correct redirect URLs |
proxy_pass_request_headers on |
Forward all client request headers to the backend (default, made explicit for clarity) |
proxy_set_header Cookie |
Forward authentication cookies (access_token, csrf_token) to the backend |
SSE (Server-Sent Events)¶
SSE endpoints require special handling to prevent buffering:
# SSE specific settings for /api/v1/events/
location ~ ^/api/v1/events/ {
proxy_pass ${BACKEND_URL};
proxy_ssl_verify off;
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 $scheme;
# Forward cookies
proxy_pass_request_headers on;
proxy_set_header Cookie $http_cookie;
# SSE configuration
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
# Disable buffering for SSE
proxy_set_header X-Accel-Buffering no;
}
| Directive | Purpose |
|---|---|
proxy_set_header Connection '' |
Clear the Connection header so nginx doesn't inject close — required for HTTP/1.1 keep-alive streaming |
proxy_http_version 1.1 |
Use HTTP/1.1 between nginx and the backend, which supports chunked transfer encoding needed by SSE |
proxy_buffering off |
Disable response buffering — pass each chunk from the backend to the client immediately |
proxy_cache off |
Disable response caching — SSE streams must never be served from cache |
proxy_read_timeout 86400s |
24-hour read timeout — SSE connections are long-lived; the default 60s would close them |
proxy_send_timeout 86400s |
24-hour send timeout — matches read timeout to prevent asymmetric timeout disconnects |
proxy_set_header X-Accel-Buffering no |
Tell nginx's upstream module to disable buffering (belt-and-suspenders with proxy_buffering off) |
Without these settings, SSE events would be buffered and delivered in batches instead of real-time.
This location block is nested inside location /api/ using a regex match (~ ^/api/v1/events/). Nesting is used here
so that the SSE location inherits the parent's proxy_pass context while adding stream-specific directives. Because
the SSE block defines its own proxy_set_header directives (e.g., Connection '', X-Accel-Buffering no), it must
redeclare all of them — proxy_set_header follows the same all-or-nothing inheritance as add_header: once a child
block defines any proxy_set_header, all parent-level proxy_set_header directives are dropped for that block.
Grafana proxy¶
server {
listen 5001 ssl;
server_name grafana.integr8scode.cc;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
resolver 127.0.0.11 valid=30s ipv6=off;
set $grafana_upstream ${GRAFANA_URL};
location / {
proxy_pass $grafana_upstream;
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 https;
}
location /api/live/ {
proxy_pass $grafana_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
Grafana is only available when the observability Docker Compose profile is active. Without it, requests to /grafana/
return 502 (expected).
The resolver + set $upstream pattern is used here so nginx resolves grafana at request time instead of at startup.
Without this, nginx would fail to start when the Grafana container is not running (e.g., when the observability
profile
is not active). Docker's embedded DNS resolver (127.0.0.11) handles container name resolution on the internal network.
| Directive | Purpose |
|---|---|
resolver 127.0.0.11 valid=30s ipv6=off |
Use Docker's embedded DNS; cache results for 30s; skip IPv6 (Docker bridge is IPv4) |
set $grafana_upstream http://grafana:3000 |
Store upstream in a variable so nginx resolves it at request time, not startup |
proxy_pass $grafana_upstream |
Forward requests to the Grafana container on the internal Docker network |
proxy_set_header Host $host |
Forward the original Host header so Grafana sees the client's hostname |
proxy_set_header X-Real-IP $remote_addr |
Pass the client's real IP address |
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for |
Append client IP to the proxy chain header |
proxy_set_header X-Forwarded-Proto $scheme |
Preserve the original protocol so Grafana can build correct redirect URLs |
Grafana must also be configured to serve from a subpath. This is done in backend/grafana/grafana.ini:
Static asset caching¶
# Cache static assets (no add_header here — server-level security headers inherit)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
}
# Cache build directory assets with long expiry
location /build/ {
expires 1y;
}
# HTML files should not be cached
location ~* \.html$ {
expires -1;
}
| Location pattern | expires value |
Effect |
|---|---|---|
~* \.(js\|css\|png\|...) |
1y |
Sets Cache-Control: max-age=31536000 — browser caches for one year |
/build/ |
1y |
Same treatment for the Svelte build output directory |
~* \.html$ |
-1 |
Sets Cache-Control: no-cache — browser must revalidate every time |
Svelte build outputs hashed filenames (app.abc123.js), making them safe to cache indefinitely. HTML files must never
be cached to ensure users get the latest asset references.
The ~* modifier makes the regex case-insensitive (matches .JS, .Css, etc.).
Nonce injection¶
Nginx replaces every occurrence of the literal string **CSP_NONCE** in HTML responses with $request_id — a unique
value generated per request. This same $request_id is interpolated into the CSP header's nonce- directive, so the
browser sees matching nonces and allows those <script> and <style> tags.
| Directive | Purpose |
|---|---|
sub_filter_once off |
Replace all occurrences, not just the first |
sub_filter '**CSP_NONCE**' '$request_id' |
Inject the per-request nonce into HTML |
sub_filter_types text/html |
Only process HTML responses (not JS, CSS, JSON) |
Nonce consumers in index.html:
<meta name="csp-nonce" content="**CSP_NONCE**">— read at runtime by CodeMirror'sgetCspNonce()so dynamically injected<style>tags carry the nonce<style nonce="**CSP_NONCE**">— initial page styles<script nonce="**CSP_NONCE**" type="module">— main app bundle<script nonce="**CSP_NONCE**">— loading spinner cleanup
Security headers¶
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$request_id'; style-src-elem 'self' 'nonce-$request_id'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; connect-src 'self';";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()";
All security headers are defined at the server level so they apply to every response. See
add_header inheritance for why no location block in this config uses add_header.
Content Security Policy¶
| Directive | Value | Purpose |
|---|---|---|
default-src |
'self' |
Fallback for unspecified directives |
script-src |
'self' 'nonce-$request_id' |
Nonce-locked — only <script> tags with the matching nonce can execute |
style-src-elem |
'self' 'nonce-$request_id' |
Nonce-locked — blocks injected <style> tags without the nonce |
style-src-attr |
'unsafe-inline' |
Allows style="" attributes (Svelte transitions, CodeMirror) |
img-src |
'self' data: blob: |
Allow data: URLs for inline SVG icons |
font-src |
'self' data: |
Allow embedded fonts |
object-src |
'none' |
Block plugins (Flash, Java) |
base-uri |
'self' |
Prevent <base> tag hijacking of relative URLs |
form-action |
'self' |
Restrict form submission targets |
frame-ancestors |
'none' |
Prevent clickjacking |
connect-src |
'self' |
XHR/fetch/WebSocket/SSE same-origin only |
Why nonce-based script-src? This is the critical XSS control. Without it, any injected <script> tag would
execute. With the nonce, only scripts carrying the per-request nonce are allowed. An attacker cannot predict the nonce
value (it changes on every request), so injected scripts are blocked.
Why style-src-elem + style-src-attr split instead of blanket style-src? CSP Level 3 splits the old
style-src into two directives. style-src-elem governs <style> tags and <link rel="stylesheet"> — the dangerous
vector where an injected <style> tag can use @font-face + attribute selectors to exfiltrate secrets
character-by-character. We lock this down with nonces. style-src-attr governs style="" attributes on elements —
much lower risk because exfiltration (e.g., background-image: url(...)) requires img-src/connect-src to allow
attacker domains, and our default-src 'self' blocks that channel entirely. We allow 'unsafe-inline' only on
style-src-attr because Svelte transitions, CodeMirror, and inline style bindings inject style="" attributes at
runtime, where nonces/hashes are impossible (CSP has no mechanism for per-attribute nonces).
Scanner false positives
Security scanners that only understand CSP Level 2 (style-src) will flag 'unsafe-inline' without recognizing
the Level 3 style-src-elem / style-src-attr split. Since style-src-elem is nonce-locked, the
'unsafe-inline' on style-src-attr does not weaken the policy — the dangerous CSS vector (<style> tag
injection) is already blocked.
Other security headers¶
| Header | Value | Purpose |
|---|---|---|
X-Frame-Options |
DENY |
Legacy clickjacking protection (aligns with CSP frame-ancestors 'none') |
X-Content-Type-Options |
nosniff |
Prevent MIME sniffing |
Referrer-Policy |
strict-origin-when-cross-origin |
Limit referrer leakage |
Permissions-Policy |
Deny geolocation, mic, camera | Disable unused APIs |
SPA routing¶
The try_files $uri $uri/ /index.html directive enables client-side routing. When a URL like /editor is requested
directly, Nginx serves index.html and lets the Svelte router handle the path.
add_header inheritance¶
Nginx has a critical inheritance rule: if any add_header directive appears inside a location block, all
add_header directives from the parent server block are silently dropped for that location. This is an all-or-nothing
behavior — there is no merging.
# BAD — security headers NOT sent for /api/v1/events/ responses
server {
add_header X-Frame-Options "DENY"; # defined at server level
location ~ ^/api/v1/events/ {
add_header Cache-Control "no-cache"; # this single add_header
# drops ALL server-level headers
}
}
# GOOD — security headers sent for all responses including SSE
server {
add_header X-Frame-Options "DENY"; # defined at server level
location ~ ^/api/v1/events/ {
proxy_buffering off; # no add_header here,
# server-level headers inherit
}
}
This config intentionally avoids add_header in every location block so that the five security headers defined at
the server level apply uniformly to all responses:
| Location | Has add_header? |
Security headers inherited? |
|---|---|---|
location /api/ |
No | Yes |
location ~ ^/api/v1/events/ |
No | Yes |
location /grafana/ |
No | Yes |
location ~* \.(js\|css\|…) |
No | Yes |
location /build/ |
No | Yes |
location ~* \.html$ |
No | Yes |
location / |
No | Yes |
Adding add_header to a location
If you need to add a response header in a specific location, you must repeat all five security headers in that
same block, or use the always parameter with an include file. Prefer solving the problem without add_header
when possible (e.g., use proxy_set_header for upstream-facing headers, or let the backend set its own response
headers).
proxy_set_header vs add_header¶
These two directives are often confused but serve different purposes:
| Directive | Direction | Affects |
|---|---|---|
proxy_set_header |
nginx → backend | Modifies request headers sent to the upstream server |
add_header |
nginx → client | Adds response headers sent to the browser |
proxy_set_header has its own inheritance rules (also all-or-nothing per block), but it does not interact with
add_header inheritance. The SSE block uses proxy_set_header X-Accel-Buffering no to tell nginx's upstream module
to disable buffering — this is a request-direction header and does not trigger the add_header inheritance problem.
Deployment¶
The nginx configuration uses environment variable substitution via the official nginx Docker image's built-in envsubst
feature:
# Production stage
FROM nginx:1.29-alpine
# Install curl for healthcheck
RUN apk add --no-cache curl
# Copy built static files
COPY --from=builder /app/public /usr/share/nginx/html
# Copy nginx config template (envsubst runs at container startup)
# The nginx image automatically processes /etc/nginx/templates/*.template
# and outputs to /etc/nginx/conf.d/ with the .template suffix removed
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
# Create writable directories for nginx (required for read-only root filesystem)
# and certs directory for TLS certificates mounted at runtime
RUN mkdir -p /var/cache/nginx /var/run /etc/nginx/certs && \
chown -R nginx:nginx /var/cache/nginx /var/run /etc/nginx/certs
EXPOSE 5001
CMD ["nginx", "-g", "daemon off;"]
The nginx image automatically processes files in /etc/nginx/templates/*.template and outputs the result to
/etc/nginx/conf.d/ with the .template suffix removed.
Environment variables¶
| Variable | Purpose | Example |
|---|---|---|
BACKEND_URL |
Backend service URL for API proxy | https://backend:443 |
Set this via the docker-compose environment section.
Rebuilding¶
To apply nginx configuration changes:
Troubleshooting¶
| Issue | Cause | Solution |
|---|---|---|
| SSE connections dropping | Default 60s proxy_read_timeout |
Verify 86400s timeout is set |
| CSP blocking resources | Missing source in directive | Check browser console, add blocked source |
| 502 Bad Gateway | Backend unreachable | docker compose logs backend |
| Assets not updating | Browser cache | Clear cache or verify no-cache on HTML |
| Security headers missing on SSE | add_header in location block |
Remove add_header from the location; see add_header inheritance |
| Security headers missing on some routes | Same inheritance issue | Verify no location block has add_header; use curl -I to check |