375bc64f2f
Backend: Alpine runtime without Chromium by default, opt-in Debian scraper target with Playwright. Frontend: Vite build served by nginx-unprivileged with the API upstream resolved at start. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
78 lines
3.0 KiB
Plaintext
78 lines
3.0 KiB
Plaintext
# Rendered by the nginx-unprivileged entrypoint: envsubst expands ${API_UPSTREAM}
|
|
# (and only names that exist in the environment — nginx's own $uri/$host/etc.
|
|
# are left alone) into /etc/nginx/conf.d/default.conf at container start.
|
|
|
|
server {
|
|
# 8080, not 80: unprivileged nginx runs as uid 101 and cannot bind <1024.
|
|
listen 8080;
|
|
listen [::]:8080;
|
|
server_name _;
|
|
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
|
|
# The bundle is already gzipped over the wire by this; Brotli would need a
|
|
# module the stock image does not carry, so gzip it is.
|
|
gzip on;
|
|
gzip_vary on;
|
|
gzip_min_length 1024;
|
|
gzip_proxied any;
|
|
gzip_types text/plain text/css application/javascript application/json
|
|
image/svg+xml application/manifest+json;
|
|
|
|
server_tokens off;
|
|
|
|
add_header X-Content-Type-Options nosniff always;
|
|
add_header X-Frame-Options DENY always;
|
|
add_header Referrer-Policy no-referrer always;
|
|
|
|
# Container-level liveness. Served from memory, so a broken bundle or a
|
|
# read-only mount cannot make this lie.
|
|
location = /healthz {
|
|
access_log off;
|
|
# default_type, not `add_header Content-Type`: add_header appends, so
|
|
# the response would carry two Content-Type headers.
|
|
default_type text/plain;
|
|
return 200 "ok\n";
|
|
}
|
|
|
|
# Same-origin API. Because the browser only ever talks to this origin, the
|
|
# backend's CORS allow-list is not exercised at all in this deployment.
|
|
location /api/ {
|
|
proxy_pass ${API_UPSTREAM};
|
|
proxy_http_version 1.1;
|
|
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;
|
|
proxy_set_header Connection "";
|
|
|
|
# The FK-scraper poll endpoints are long-ish; don't cut them at 60s.
|
|
proxy_read_timeout 120s;
|
|
proxy_buffering off;
|
|
}
|
|
|
|
# Vite emits content-hashed filenames under /assets, so they are immutable.
|
|
location /assets/ {
|
|
access_log off;
|
|
# `expires 1y` would emit its own Cache-Control alongside this one;
|
|
# one explicit header is clearer than two contradicting-looking ones.
|
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
|
try_files $uri =404;
|
|
}
|
|
|
|
# index.html is the only mutable entry point — never cache it, or a deploy
|
|
# leaves clients pinned to a stale asset graph.
|
|
location = /index.html {
|
|
add_header Cache-Control "no-cache" always;
|
|
}
|
|
|
|
# SPA fallback. react-router-dom uses BrowserRouter, so a hard refresh or a
|
|
# shared link on a client-only route (/calendar, /settings, ...) arrives
|
|
# here as a real HTTP request for a path with no file behind it. Without
|
|
# this line that is a 404 and client-side routing breaks on reload.
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
}
|