Files
Yuriy PanovandClaude Opus 5 9736024e60 deploy: fix nginx security headers dropped by add_header inheritance
Found while verifying the first real provision: none of the four
security headers reached the browser on any HTML page.

nginx's add_header inheritance is all-or-nothing — a location that sets
any add_header of its own discards every header inherited from the
server block. "/" resolves through try_files to `location = /index.html`,
which sets Cache-Control, so HSTS, X-Content-Type-Options,
X-Frame-Options and Referrer-Policy were silently dropped exactly where
they matter. /assets/ lost them the same way.

Move the four into snippets/security-headers.conf and include it in the
server block and in both locations that add a header of their own.

Also from the same provision run:
- ssl_stapling is dead config now that Let's Encrypt certificates carry
  no OCSP responder URL; it only logs a warning per cert on each reload;
- README step 3 chmod'ed /etc/exo to 750 but never set its group, so the
  exo user could not traverse it and exo-deploy died on "cannot read
  /etc/exo/<app>.env";
- README step 7 dropped http-extras.conf into conf.d without disabling
  the same directives in Debian's stock nginx.conf, and nginx refuses to
  start on a duplicate gzip / server_tokens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:56:16 +06:00

88 lines
3.0 KiB
Plaintext

# Rendered by deploy/bin/exo-render-nginx from deploy/apps.conf.
# Placeholders: {{APP}} {{DOMAIN}} {{PORT}}
#
# Layout: nginx serves the built SPA straight off disk and proxies only /api/*.
# A restarting or crashed Node process therefore takes down the form, not the page.
limit_req_zone $binary_remote_addr zone=leads_{{APP}}:10m rate=10r/m;
# --- ACME challenge + http -> https ---------------------------------------
server {
listen 80;
listen [::]:80;
server_name {{DOMAIN}} www.{{DOMAIN}};
# Must come before any try_files fallback, or certbot's challenge 404s.
location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://{{DOMAIN}}$request_uri; }
}
# --- www -> apex -----------------------------------------------------------
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name www.{{DOMAIN}};
ssl_certificate /etc/letsencrypt/live/{{DOMAIN}}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{DOMAIN}}/privkey.pem;
include /etc/nginx/snippets/ssl-params.conf;
return 301 https://{{DOMAIN}}$request_uri;
}
# --- the site --------------------------------------------------------------
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name {{DOMAIN}};
ssl_certificate /etc/letsencrypt/live/{{DOMAIN}}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{DOMAIN}}/privkey.pem;
include /etc/nginx/snippets/ssl-params.conf;
root /srv/exo/{{APP}}/current/dist/client;
index index.html;
include /etc/nginx/snippets/security-headers.conf;
# Vite fingerprints everything under /assets — cache for a year.
location ^~ /assets/ {
access_log off;
# Re-included: this location's own add_header would drop the inherited ones.
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
# The one file that must never be cached, or a deploy goes unseen.
# `/` lands here via try_files, so this is where the security headers
# actually have to be re-stated (see snippets/security-headers.conf).
location = /index.html {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "no-cache" always;
}
location = /robots.txt { access_log off; }
location = /sitemap.xml { access_log off; }
location = /api/health {
access_log off;
proxy_pass http://127.0.0.1:{{PORT}};
include /etc/nginx/snippets/proxy-params.conf;
}
# Second layer of rate limiting: the app's own limiter is in-memory and
# resets on restart (server/src/rate-limit.ts).
location /api/ {
limit_req zone=leads_{{APP}} burst=5 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:{{PORT}};
include /etc/nginx/snippets/proxy-params.conf;
}
location / { try_files $uri $uri/ /index.html; }
}