The problem
A frontend build with no caching discipline means every visitor re-downloads the same unchanged JavaScript on every page load, or — worse — caches a stale bundle and silently runs old code after a deploy. Cache too little and repeat visitors pay a full download for bytes that never changed; cache too aggressively without a way to invalidate, and a deploy goes out while browsers keep serving code from before it.
Separately, a default nginx reverse-proxy config leaves a set of well-understood, low-cost security headers unset. None require application changes — they're response headers a browser will honor the moment they're present — but if nobody adds them, the app ships without protections it could have had for free.
The alternatives
Both problems could have been solved at other layers, and each option carried its own trade-off.
- Application-level cache headers — the framework can set
Cache-Controlper response, but every static asset request still wakes a process to resolve a file path and write headers, when a static file server does the same job without touching application code. - A CDN in front of the app — offloads caching entirely, but it's another moving part and another bill for a single-box deployment that doesn't yet have the traffic to justify it.
- Cache-busting query strings (
app.js?v=123) — depends on every reference remembering to bump the version, and some intermediate caches ignore query strings for cache-key purposes entirely, defeating the busting. - Leaving security headers to framework middleware — one more dependency to keep current, and easy for a header to quietly disappear if the middleware isn't applied to every route consistently.
With nginx already the reverse proxy in front of the app, both problems had a single, cheap home: a handful of directives at the edge, applied once for every response.
The decision
Two servers, one redirect
The config, taken from the host nginx file in front of a FastAPI backend on 127.0.0.1:8001, runs as two server blocks. Port 80 does exactly one thing — no location logic, no proxying, just a redirect, because there's no legitimate reason for this app to ever answer a plaintext request:
server {
listen 80;
server_name app.example.cloud;
return 301 https://$host$request_uri;
}
The real server listens on 443 with ssl http2, pointing at certbot-managed certificate paths and pulling in Let's Encrypt's own recommended TLS options and Diffie-Hellman parameters rather than hand-rolling cipher suites — kept current by certbot renew instead of copy-pasted from a blog post and left to rot:
server {
listen 443 ssl http2;
server_name app.example.cloud;
ssl_certificate /etc/letsencrypt/live/app.example.cloud/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.cloud/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
...
}
ssl_certificate is the full chain — the site's own certificate plus the intermediate certificates that let a browser walk trust back to a root it already recognizes, which is why it points at fullchain.pem and not the bare leaf cert. ssl_certificate_key is the private key that proves this server actually owns that certificate; it never leaves the box. Both live under a path certbot manages and rewrites in place on renewal, so there's nothing to update by hand every 90 days. ssl_dhparam supplies the Diffie-Hellman parameters used for perfect-forward-secrecy key exchange — generated once, not something nginx can derive on its own — and the options-ssl-nginx.conf include is certbot's own recommended protocol and cipher list, kept current without this config having an opinion on TLS internals.
http2 on that same listen line is doing real work, not just a version bump. HTTP/1.1 opens one request per TCP connection at a time — browsers work around it with multiple parallel connections, each paying its own handshake — while HTTP/2 multiplexes many requests over a single connection, interleaving their responses instead of queuing them. For a page that pulls down several hashed JS/CSS chunks after the initial HTML, that means one connection instead of six, and no chunk stuck waiting behind a slower one on the same pipe (the head-of-line blocking HTTP/1.1 has at the connection level). It also compresses headers instead of repeating them verbatim on every request. None of this changes what the app returns — it's a transport-level upgrade that costs nothing but the http2 keyword once TLS is already terminated here.
Everything else in this article — gzip, security headers, caching, routing — lives inside this same 443 block. The port-80 server above exists only to redirect into it.
Cache-busting without a CDN
The frontend build pipeline writes every JS and CSS chunk with a content hash in the filename — something like index-B3xK9dQ2.js. Change one line of code, and the file gets a new name. That naming scheme is a contract, and nginx exploits it:
location /assets/ {
access_log off;
expires 1y;
add_header Cache-Control
"public, max-age=31536000, immutable";
}
One year, immutable — the browser will never even ask if the file changed, because it can't change: a changed file is a different URL. access_log off is there too, since logging every hashed-asset hit is pure noise once caching is doing its job.
The critical other half of the contract is what's not long-cached. Two separate SPA locations handle the split:
location = / {
try_files /landing.html =404;
}
location / {
try_files $uri $uri/ /index.html;
}
The bare root is served from a separate build entry — a marketing landing page compiled independently of the app — while every other path falls through to the app's own index.html for client-side routing to take over. Neither of those two HTML responses matches the /assets/ path, so they get nginx's default caching instead of the year-long immutable one: browsers revalidate them on every load. The HTML is the pointer; the hashed assets under /assets/ are the immutable data it points to. Deploys are instant and atomic from the browser's perspective — fetch the new HTML, and it references the new hashes.
An immutable cache header is only as trustworthy as the naming contract behind it — the moment an unhashed filename leaks into a long-cached path, you've promised the browser something you can't keep.
That trade-off is worth being explicit about: the entire scheme depends on every build artifact under that path being content-hashed. If a build step ever emitted a static, unhashed filename into that same location, a browser would cache a stale version of it for a year with no way to force a refresh short of a URL change. The fix isn't more nginx config — it's keeping the build pipeline's naming discipline airtight, since that's the actual guarantee the cache header is resting on.
Gzip, once, at the edge
The FastAPI backend returns JSON; the SPA build emits JS and CSS. Compressing all of it happens once, in nginx, rather than in every response handler:
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types application/json text/plain text/css application/javascript;
gzip_proxied any matters because these are proxied responses from the FastAPI upstream, not static files nginx reads off disk — by default nginx is conservative about compressing proxied content, and this opts back in. gzip_vary on adds Vary: Accept-Encoding so shared caches don't serve a compressed response to a client that didn't ask for one. Compression level runs 1 (fastest, weakest) to 9 (smallest, most CPU) — 1-3 barely shrinks the payload, and 7-9 spends noticeably more CPU per request for only a small extra size reduction over 6. The 4-6 range is what most production configs converge on, and this one sits at the top of it: it's the point where compression is still cheap per request, so the trade-off pays off on every proxied JSON response without needing per-route tuning.
The headers and surface-reduction extras
Once nginx is the front door, a handful of one-line directives buy real security posture at effectively no cost:
add_header Strict-Transport-Security
"max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; upgrade-insecure-requests;" always;
add_header Permissions-Policy
"camera=(), microphone=(), geolocation=()" always;
The CSP is scoped, not just present: script-src 'self' allows no inline or third-party scripts at all, so a reflected-XSS payload has nowhere to execute from even if it slips past input handling. style-src and font-src carve out exceptions for Google Fonts specifically — the one third-party origin the app actually loads assets from — while img-src stays looser (data: and any https:) since images are lower-risk. Permissions-Policy switches off camera, microphone, and geolocation — APIs this app never calls.
A security header that's merely present isn't the same as one that's scoped to what the app actually does — script-src 'self' only earns its keep because nothing in the app needs an inline script or a third-party script host.
Surface reduction goes further than headers. The interactive API docs are switched off entirely in this environment, and the health check is fenced off by source IP rather than by header or token:
location /api/docs { return 444; }
location /api/redoc { return 444; }
location = /api/health {
allow 127.0.0.1;
deny all;
access_log off;
proxy_pass http://fastapi_backend_beta/api/health;
}
444 is nginx-specific — it closes the connection without sending any HTTP response at all, not even a 404 that would confirm something is listening there. The health check exists for the box itself — a process supervisor or monitoring agent running locally — not the public internet, so restricting it to 127.0.0.1 is simpler and harder to misconfigure than an application-level auth check.
Finally, every proxied location forwards X-Real-IP, X-Forwarded-For, and X-Forwarded-Proto to the FastAPI upstream, and the upstream block sets keepalive 32. Without those forwarded headers the app would see every request as coming from 127.0.0.1 — the reverse proxy's own address — losing the real client IP for logging and any IP-based logic; keepalive 32 keeps a pool of already-open connections to the upstream instead of paying a TCP handshake per request. (Rate limiting on the login and contact endpoints lives at the same layer, but is its own story — covered separately.)
The outcome
Static assets are served with year-long immutable caching while the entry HTML stays revalidated, so repeat visitors stop re-downloading unchanged bundles and deploys still take effect immediately on next load, with no cache-busting query strings or CDN invalidation calls required. The security headers, TLS enforcement, and closed-off documentation and health-check endpoints cost nothing beyond the initial configuration and remain in force by default rather than depending on every response handler in the application remembering to set them.