Almost nobody writes an nginx config from scratch. They find a snippet on a blog that nearly works, paste it, and edit until the site loads. Then WebSockets fail, or the app logs every request as coming from 127.0.0.1, or a deploy leaves visitors staring at a stale page. nginx generator exists because the directive you actually wanted is rarely alone — it emits the ones that have to accompany it, and says what each costs.
proxy_pass is never enough on its own
A bare proxy line does two silent, wrong things: it drops the connection upgrade WebSockets need, and it hides the real client from your app. The block that actually works looks like this:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
}
The first three lines after proxy_pass are what make WebSockets survive the hop: HTTP/1.1, plus passing the Upgrade and Connection headers through. Leave them out and the handshake never completes, which is why a chat feature or a live reload "works locally" and dies behind the proxy.
The four X-Forwarded and Host lines are what let your app know who it is talking to. Without them the application sees nginx, not the visitor — so rate limiting keys on the wrong address, logs are useless, and any code that trusts X-Forwarded-Proto to detect HTTPS decides every request is plain HTTP.
The cache split that a blog snippet gets wrong
The tempting mistake is one caching rule for everything. But a modern build produces two very different kinds of file, and they want opposite policies.
Hashed assets — a file whose name contains a content hash — can be cached forever, because when the content changes the name changes:
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
HTML must never be cached that way, because its URL stays the same while its content changes every deploy:
location = /index.html {
add_header Cache-Control "no-cache";
}
Get this backwards — cache the HTML aggressively — and a visitor's browser keeps serving yesterday's page, which points at asset filenames that this deploy has already deleted. The result is a blank or broken app for anyone who visited before the release, and it clears only when they hard-refresh. The immutable-assets, revalidated-HTML split is the whole trick.
Why copying a snippet bites you
There is one more trap the generator handles: add_header inside a location block silently discards every header the server block set. So the moment you add one cache header in a location, your security headers from higher up vanish unless you repeat them. A blog snippet showing a single location rarely mentions this, because in isolation it looks fine.
The generator produces the static-site, single-page-app and reverse-proxy shapes with the accompanying directives already in place, and tells you what each one is for before you paste it onto a server. Related generators cover the systemd unit that keeps the upstream running and the docker-compose file that starts it.