A wrong nginx reverse proxy configuration does not fail loudly. It fails by logging 127.0.0.1 as every client IP, redirecting browsers into an infinite loop, or silently killing WebSockets, and you only notice in production. The cause is almost always the proxy headers: nginx does not forward the original Host, the client IP, or the original scheme unless you tell it to, and hop-by-hop headers like Upgrade are dropped by default. The fix is a small, deliberate set of proxy_set_header lines plus an upstream block. This post walks a complete server block line by line so you can see exactly what each directive does and what breaks when it is missing.
What does a reverse proxy actually change about the request?
When nginx sits in front of your Node app, a PHP-FPM pool, or a container, the upstream no longer talks to the client directly. It talks to nginx. From the app's point of view every connection now originates from nginx (127.0.0.1 or a Docker bridge address), arrives over plain HTTP even when the client used HTTPS, and carries whatever Host header nginx decides to send. Your application's logging, rate limiting, redirect generation, and cookie security all depend on knowing the real client and the real scheme, and none of that survives the proxy hop unless you reconstruct it with headers. That reconstruction is the entire job of the proxy headers, and getting it wrong is the single most common reverse-proxy bug I see.
The upstream block: why not just proxy_pass to a port?
You can write proxy_pass http://127.0.0.1:3000 directly, and for a single backend it works. But an explicit upstream block buys you three things: a name you can reuse, keepalive connections so nginx is not opening a fresh TCP socket per request, and a place to add a second backend later without touching the location block. The keepalive directive is the part people skip, and on a busy Node app it measurably cuts latency because you stop paying the TCP connection-setup cost on every single request.
# Named upstream: one place to define the backend.
upstream app_backend {
server 127.0.0.1:3000;
# server 127.0.0.1:3001; # add a second worker, get round-robin for free
# Reuse connections to the backend instead of opening one per request.
# Requires HTTP/1.1 + a non-close Connection header in the proxy block below.
keepalive 32;
}Walking the server block line by line
Here is the full server block. Every line below it explains what that directive does and the consequence of omitting it. This assumes nginx is terminating TLS on 443 (Certbot-issued certs) and proxying to the upstream defined above. If your TLS is handled further out — at an AWS load balancer, for example — the X-Forwarded-Proto logic still matters, and I cover that case below.
# Maps the incoming Upgrade header to the right Connection value.
# Must live at http{} scope, so put it at the top of the file or in conf.d.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on; # nginx 1.25.1+: http2 is its own directive
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
# --- Serve static assets directly; never proxy these to the app. ---
location /static/ {
alias /var/www/app/public/static/;
access_log off;
expires 30d;
}
# --- Everything else goes to the app. ---
location / {
proxy_pass http://app_backend;
# 1. Preserve the hostname the client actually requested.
proxy_set_header Host $host;
# 2. The real client IP (single value) for app-side logging/geo.
proxy_set_header X-Real-IP $remote_addr;
# 3. Append this hop to the chain of client IPs.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 4. Tell the app the ORIGINAL scheme. Without this, an app
# behind TLS thinks it is on http:// and builds http:// redirects.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# 5. WebSocket support: forward the hop-by-hop upgrade headers.
# The map drives Connection: it is 'upgrade' for a WebSocket
# request and 'close' otherwise. Do NOT add a second
# 'proxy_set_header Connection ""' line below it -- a duplicate
# Connection directive wins and silently breaks the upgrade.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# HTTP/1.1 is required for both WebSocket upgrades and upstream keepalive.
proxy_http_version 1.1;
# --- Buffering and timeouts ---
proxy_buffering on; # buffer the response (default)
proxy_read_timeout 60s; # max idle between reads FROM upstream
proxy_send_timeout 60s; # max idle while sending TO upstream
proxy_connect_timeout 5s; # fail fast if the app is down
}
}Why proxy_set_header Host $host matters
By default nginx sends the Host header as the upstream's name (the value in proxy_pass), not the hostname the client typed. If your app does multi-tenant routing by Host, generates absolute URLs, or sets cookies scoped to a domain, it will misbehave the moment that header is wrong. $host gives the app the public hostname. Use $host rather than $http_host because $host is normalised and falls back to server_name if the client sends no Host header at all.
X-Real-IP versus X-Forwarded-For
These are not interchangeable. X-Real-IP is a single address, the immediate client as nginx sees it. X-Forwarded-For is a comma-separated chain built by $proxy_add_x_forwarded_for, which appends $remote_addr to any existing header. Behind one proxy they look the same; behind a chain (CDN to load balancer to nginx to app) only X-Forwarded-For preserves the full path. Your app should trust the leftmost entry only when every proxy in front of it is one you control, otherwise a client can spoof it.
The redirect loop that X-Forwarded-Proto fixes
This is the bug that costs people an afternoon. You terminate TLS at nginx (or an upstream load balancer) and proxy plain HTTP to the app. The app, seeing an http:// request, decides the user should be on HTTPS and issues a 301 to the https:// version. The browser follows it, hits nginx over HTTPS again, nginx forwards plain HTTP to the app again, the app sees http:// again, and redirects again. Infinite loop, ERR_TOO_MANY_REDIRECTS. The app is not broken; it simply never learns the original request was already HTTPS. proxy_set_header X-Forwarded-Proto $scheme tells it. Then you configure the framework to trust that header — Laravel's TrustProxies middleware, Express's app.set('trust proxy', ...), Django's SECURE_PROXY_SSL_HEADER — and the loop disappears.
Half the reverse-proxy tickets I have ever closed were one missing X-Forwarded-Proto header. The app was fine. It just never knew the request started as HTTPS.
Why do my WebSockets die at the proxy?
Upgrade and Connection are hop-by-hop headers. HTTP defines them as applying to a single connection, so nginx does not forward them to the upstream by default — which means the WebSocket handshake never completes and the connection drops or hangs. You restore them with the two header lines and the map block. The map exists because you cannot hardcode Connection: upgrade: a normal HTTP request to the same location has no Upgrade header, and forcing Connection: upgrade on it is wrong. The map sends Connection: upgrade only when an Upgrade header is present, and Connection: close otherwise. One trap worth calling out: set Connection from the map and nothing else. If you also add a literal proxy_set_header Connection "" in the same location for keepalive, that duplicate directive overrides the map variable, Connection: upgrade is never sent, and every WebSocket silently fails — which is exactly the bug this whole post is meant to prevent.
- proxy_set_header Upgrade $http_upgrade — forwards the client's Upgrade: websocket header to the app.
- proxy_set_header Connection $connection_upgrade — driven by the map, so WebSocket requests get Connection: upgrade and everything else gets Connection: close.
- Set Connection from the map only. A second proxy_set_header Connection directive in the same block wins and breaks the upgrade.
- The map $http_upgrade block must sit at http{} scope, not inside server{} or location{}, or nginx will fail to load the config.
- proxy_read_timeout matters here too: a long-lived WebSocket with no traffic for longer than the timeout gets cut. Raise it for chat or live-update apps that can sit idle.
One nginx-version note worth knowing: as of nginx 1.30.0 (April 2026) upstream keepalive is enabled by default and the proxy HTTP version defaults to 1.1, so the explicit proxy_http_version 1.1 line is no longer strictly required for WebSockets on the newest builds. I keep it in the config anyway because it is harmless, self-documenting, and correct on every version you are still likely to be running on Ubuntu 22.04 or 24.04, both of which ship nginx older than 1.30.
Buffering, timeouts, and serving static files
proxy_buffering is on by default and you usually want it: nginx reads the full response from the app as fast as the app can produce it, frees the backend, then trickles the bytes out to a slow client at the client's pace. Turn it off (proxy_buffering off) only for streaming responses — Server-Sent Events, a download proxied from object storage, an LLM token stream — where you need bytes to reach the client immediately rather than after nginx has buffered them. The timeouts are your circuit breakers: proxy_connect_timeout should be short (a few seconds) so a dead backend fails fast, while proxy_read_timeout governs how long nginx waits between reads from a slow-but-alive upstream. A report that takes 90 seconds to generate behind a default 60s proxy_read_timeout returns a 504, and the fix is raising the timeout on that location, not blaming nginx.
The other half of doing this well is not proxying what you do not have to. A request for /static/app.css does not need to wake your Node process or occupy a PHP-FPM worker. The location /static/ block above serves those files straight off disk with nginx's sendfile path and an expires header, which is far faster and frees the app to do real work. Match your asset path to wherever your build actually writes files, and confirm nginx is winning the location match — most accidental proxying of static assets is a location-precedence mistake, not a missing block.
What if TLS is terminated upstream, not at nginx?
If an AWS Application Load Balancer terminates TLS and forwards HTTP to your EC2 instance, nginx itself sees plain HTTP, so $scheme is http and an X-Forwarded-Proto built from $scheme would be wrong. In that setup you read the ALB's own X-Forwarded-Proto header instead of overwriting it with $scheme — the same redirect-loop logic applies, just one hop further out. I cover that whole front end, including listener and target-group setup, in my guide to putting an Application Load Balancer in front of HTTPS traffic. And if your pain is the opposite — nginx and Certbot stepping on each other's server blocks after a renewal — the fixes are in resolving Nginx and Certbot SSL conflicts. When you reload this config to ship a change, do it the safe way described in my zero-downtime PHP deployment notes so an in-flight request is never dropped mid-reload.
Test before you reload, every time
Never reload nginx on faith. A syntax error or a bad cert path takes the whole server down on reload, and the error only surfaces when the reload fails. Run the config test first, and only reload if it passes.
# Validate syntax and that referenced files (certs, includes) exist.
sudo nginx -t
# Only reload if the test passed. -t returns non-zero on failure.
sudo nginx -t && sudo systemctl reload nginx
# Confirm the new client IP is reaching the app, not 127.0.0.1.
sudo tail -f /var/log/nginx/access.logA reverse proxy is not complicated, but it is unforgiving: the defaults are wrong for almost every real application, and the failures are quiet. Set Host so multi-tenant routing and cookies work, set X-Real-IP and X-Forwarded-For so your logs show real clients, set X-Forwarded-Proto so your app stops fighting its own redirects, and drive Connection from a single map so WebSockets live. Then run nginx -t before every reload. Get those five things right and the proxy disappears into the background, which is exactly where you want it.

