3 min read

Get an nginx reverse proxy right the first time

The directives that must ride along with proxy_pass — WebSocket upgrade, the forwarded headers — plus the cache split that keeps a deploy from serving stale pages.

nginxdevopswebapps

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.

Try it

More writing