A minimal nginx reverse proxy
Most reverse-proxy tutorials are eighty lines long and you copy all of it without knowing which parts matter. Here is the small core that actually does the work: terminate TLS, forward to the app on localhost, pass the real host and client IP, and don't strip the headers that websockets need. Everything beyond this is tuning.
The config that does the job
server {
listen 443 ssl;
http2 on;
server_name app.example;
ssl_certificate /etc/letsencrypt/live/app.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
That's the whole thing. The rest of this note is why each block is there, so you can debug it when something downstream behaves oddly.
What each header is for
By default nginx talks to your app with a stripped-down request, and the app has
no idea who the real client was. The four X-Forwarded-* / Host headers put
that information back:
Host $host— your app sees the domain the browser actually asked for, not127.0.0.1. Frameworks use this to build absolute URLs and to match virtual hosts.X-Real-IP/X-Forwarded-For— the client's real address. Without these every request looks like it came from127.0.0.1, which breaks rate limiting, geolocation, and access logs.$proxy_add_x_forwarded_forappends to any existing chain, so it survives a second proxy in front.X-Forwarded-Proto $scheme— tells the app the original request was HTTPS. nginx terminated TLS and talks to the app over plain HTTP, so without this header the app thinks it's onhttp://and generates insecure links, breaks OAuth redirects, and triggers redirect loops. This is the single most common reverse-proxy bug.
The websocket pair
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
HTTP/1.0 has no concept of connection upgrade, so you must force 1.1. The
Upgrade and Connection headers are what turn a normal request into a
websocket. Forget them and the socket doesn't error — it just hangs, then times
out with no message in any log. If "realtime works locally but not in
production," this pair is almost always why.
Two failure modes to recognise instantly
- App generates
http://links behind TLS. Missing or wrongX-Forwarded-Proto. Check that the app is configured to trust the forwarded header — many frameworks ignore it unless you whitelist the proxy. - Websockets / SSE hang forever. Missing the
Upgrade/Connectionpair orproxy_http_version 1.1.
Timeouts and buffering, when you need them
The defaults are fine for a normal web app. Two adjustments come up often enough to know:
proxy_read_timeout 300s; # long-lived SSE / slow upstream responses
proxy_buffering off; # stream responses instead of buffering them whole
Raise proxy_read_timeout if a long polling or streaming endpoint gets cut off
at the default 60 seconds. Turn proxy_buffering off when you want bytes to reach
the client as the app produces them — server-sent events, progress streams — at
the cost of nginx no longer shielding a slow client from your app.
Always test before reload
sudo nginx -t && sudo systemctl reload nginx
nginx -t parses the config and checks the certificate paths without touching
the running server. reload swaps config gracefully — in-flight requests finish
on the old workers, new ones use the new config, no dropped connections. Never
restart a production proxy when reload will do; restart drops every live
connection, reload doesn't.
Get these few directives right and the proxy disappears into the background, which is exactly what you want from it.
Common problems
502 Bad Gateway right after adding proxy_pass
nginx reached the upstream and got nothing back. Almost always the app isn't
actually listening where you pointed it. Test the upstream directly on the box:
curl -v http://127.0.0.1:3000. If that fails too, the problem is the app or its
bind address (a service listening on 127.0.0.1 only won't answer a container or
another host), not nginx.
The app redirects in a loop or builds http:// links
Missing or untrusted X-Forwarded-Proto. nginx terminates TLS and speaks plain
HTTP to the app, so without that header the app thinks the request was insecure.
Set the header and configure the framework to trust the proxy — most ignore
forwarded headers until you whitelist the proxy address.
Websockets connect, then drop after about a minute
Two separate causes. If they never upgrade at all, you're missing the
Upgrade/Connection pair or proxy_http_version 1.1. If they work but die at
60 seconds, that's the default proxy_read_timeout — raise it for long-lived
connections: proxy_read_timeout 3600s;.