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>
88 lines
3.0 KiB
Plaintext
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; }
|
|
}
|