Reverse Proxy & Load Balancing
Configuring proxy_pass with forwarded headers, and load balancing across an upstream pool of servers.
A complete reverse proxy config
Here is a full server block that proxies all requests to a backend application server (e.g. a Node.js app running on port 3000), forwarding the headers a backend needs to see the real client:
server {
listen 80;
server_name app.example.com;
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 Connection "";
}
}
Why the headers matter
Without any header forwarding, the backend application sees every single request as coming from 127.0.0.1 (Nginx itself) — it has no idea who the real client actually is. That breaks IP-based rate limiting, geo-IP lookups, access logs, and abuse detection on the backend. Each header solves a specific piece of this:
Host $host— forwards the originalHostheader the client sent, so the backend (which might serve multiple domains) knows which site was requested.X-Real-IP $remote_addr— the single IP address that connected directly to Nginx. Simple, but only correct if Nginx itself is the first hop (nothing else is proxying in front of it).X-Forwarded-For $proxy_add_x_forwarded_for— the client's IP appended onto any existingX-Forwarded-Forvalue already on the request. This is the standard header for the "chain of proxies" a request may have passed through, and the one most frameworks and libraries look for.X-Forwarded-Proto $scheme— tells the backend whether the original client request washttporhttps. Critical when Nginx terminates TLS in front of a backend that only speaks plain HTTP — without this, the backend might generatehttp://links/redirects even though the user is on a secure connection.
Most web frameworks (Laravel included, via "trusted proxies") read these headers specifically to reconstruct the real client IP and protocol — configuring the proxy correctly on the Nginx side is only half the job; the backend also needs to be told to trust these headers from Nginx's IP.
Load balancing with upstream
To distribute traffic across multiple backend servers instead of just one, define an upstream block naming the pool, then point proxy_pass at it by name:
upstream backend_pool {
server 10.0.0.11:3000;
server 10.0.0.12:3000;
server 10.0.0.13:3000;
}
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://backend_pool;
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;
}
}
Load balancing methods
The upstream block supports different distribution strategies, chosen with a directive at the top of the block:
Round robin (the default — no directive needed):
upstream backend_pool {
server 10.0.0.11:3000;
server 10.0.0.12:3000;
server 10.0.0.13:3000;
}
Requests cycle through the listed servers in order. Simple and effective when servers and request costs are roughly uniform.
Least connections:
upstream backend_pool {
least_conn;
server 10.0.0.11:3000;
server 10.0.0.12:3000;
server 10.0.0.13:3000;
}
Sends each new request to whichever backend currently has the fewest active connections. Better than round robin when requests can take very different amounts of time to process.
IP hash:
upstream backend_pool {
ip_hash;
server 10.0.0.11:3000;
server 10.0.0.12:3000;
server 10.0.0.13:3000;
}
Routes a given client's IP to the same backend server consistently, on every request. Useful when a backend holds in-memory session state and you need a client to keep landing on the same server ("session stickiness") — though the more robust fix is usually to move session state out of the application server entirely (Redis, a shared database) so any server can handle any request.
Weighted (combinable with round robin or least_conn):
upstream backend_pool {
server 10.0.0.11:3000 weight=3;
server 10.0.0.12:3000 weight=1;
server 10.0.0.13:3000 down; # temporarily removed from rotation
}
weight=3 sends roughly three times as much traffic to that server as a default weight=1 server — useful when backend machines have different capacity. down marks a server as unavailable without deleting the line (handy for planned maintenance).
Common mistakes
- Forgetting
X-Forwarded-Proto, so a backend behind HTTPS-terminating Nginx generates insecurehttp://links or redirect loops. - Assuming
X-Real-IPis trustworthy when there might be another proxy or CDN in front of Nginx — in that caseX-Forwarded-For(which accumulates the whole chain) is the header to inspect, and only the last IP appended by your own trusted Nginx should be believed. - Using
ip_hashfor session stickiness as a permanent fix rather than a stopgap — it also unevenly distributes load if a large fraction of users sit behind a shared corporate/NAT IP. - Not health-checking backend servers (open-source Nginx has basic passive checks via
max_fails/fail_timeout; active health checks require Nginx Plus or a sidecar) — a dead backend can keep receiving traffic longer than expected.