A minimal nginx reverse proxy

· nginx networking

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, not 127.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 from 127.0.0.1, which breaks rate limiting, geolocation, and access logs. $proxy_add_x_forwarded_for appends 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 on http:// 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

  1. App generates http:// links behind TLS. Missing or wrong X-Forwarded-Proto. Check that the app is configured to trust the forwarded header — many frameworks ignore it unless you whitelist the proxy.
  2. Websockets / SSE hang forever. Missing the Upgrade/Connection pair or proxy_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;.