Nginx as a Reverse Proxy and Load Balancer

Basic reverse proxy

server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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_pass forwards the request to the backend named. Without the proxy_set_header lines, the backend sees Nginx's own IP as the client, and the original Host header is replaced with the upstream address — breaking anything backend-side that logs client IPs, applies per-IP rate limiting, or relies on the original hostname. These four headers are close to mandatory for any real reverse-proxy setup, not an optional hardening step.

Load balancing across multiple backends

upstream api_backend {
    least_conn;
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
    server 10.0.0.3:8080 weight=2;
}

server {
    location / {
        proxy_pass http://api_backend;
    }
}
graph LR client["Client"] --> nginx["Nginx"] nginx --> b1["10.0.0.1:8080"] nginx --> b2["10.0.0.2:8080"] nginx --> b3["10.0.0.3:8080 (weight=2)"]

The upstream block names a pool of backend servers; proxy_pass references the pool by name instead of one fixed address. This is a direct, concrete instance of the load balancing algorithms already covered conceptually — least_conn here is the same least-connections algorithm from that page, and the default (no directive specified) is round robin. weight=2 sends roughly twice as much traffic to that server, the weighted-round-robin variant from the same page, useful when backend instances have different capacity (a bigger machine in a mixed fleet).

Passive health checks

upstream api_backend {
    server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;
}

Open-source Nginx does passive health checking only: after max_fails consecutive failed proxy attempts to a server within fail_timeout, Nginx marks it unavailable and stops routing to it for fail_timeout before trying again. This is reactive — a backend has to actually fail a real client's request before Nginx notices, unlike an active health check that proactively probes backends on a timer (available in Nginx Plus or via third-party modules, not in stock open-source Nginx).

Common pitfall

Omitting proxy_set_header X-Forwarded-For (or not correctly appending to an existing one via $proxy_add_x_forwarded_for rather than overwriting it) silently breaks any backend logic that depends on the real client IP — a rate limiter keyed on client IP would instead key on Nginx's own IP for every request, effectively rate-limiting the proxy as if it were a single client, not each real client individually. This is easy to miss entirely in testing (a direct connection to the backend during development has no proxy in the way at all) and only surfaces once traffic is actually flowing through the proxy in a real environment.