LFCE 57 🐧 Reverse Proxy with Nginx
Nginx is the most widely deployed reverse proxy in the Linux ecosystem, and its ngx_http_proxy_module is the component that makes it one. The previous chapter covered the concepts: the request flow, the forwarding headers, the load-balancing algorithms, and the health checks. This chapter covers the Nginx configuration that implements those concepts: the proxy_pass directive, the proxy_set_header directives that preserve the client’s identity, the upstream block that defines the backend pool, and the passive health checks that remove failed backends from rotation.
The configuration is built from three layers. The upstream block defines the pool of backend servers and the load-balancing algorithm. The location block selects which requests are proxied and where they are sent. The proxy_set_header directives, placed inside the location block, control what the backend sees. A proxy that forwards requests but omits the headers produces a backend that logs the proxy’s IP address and generates redirects with the wrong scheme; a proxy that includes them produces a backend that behaves as if it were serving the client directly.
Key point: The proxy_pass directive is the core of the reverse proxy configuration. The proxy_set_header Host $host; directive preserves the client’s hostname, and the X-Forwarded-For and X-Forwarded-Proto headers preserve the client’s IP and protocol. The upstream block defines the backend pool, and the max_fails and fail_timeout parameters control the passive health checks . Always run nginx -t before reloading; a configuration error prevents the new configuration from being applied.
Why Nginx’s proxy configuration requires explicit headers
The header-forwarding problem.
By default, Nginx sends the backend a Host header containing the backend’s own hostname, not the client’s. It sends the proxy’s IP address in the X-Real-IP header only if configured to do so, and it does not add X-Forwarded-For or X-Forwarded-Proto at all. The backend therefore sees the proxy as the client and the internal protocol as the scheme. An application that constructs absolute URLs from the Host header or enforces HTTPS based on the request scheme produces incorrect redirects, which can loop when the proxy also redirects .
The upstream-pool problem.
A single proxy_pass to a single backend works for a small deployment, but it provides no load balancing and no failover. The upstream block defines a pool of backends, and the proxy_pass directive references the pool by name instead of an address. The pool can contain weights, backup servers, and health-check parameters that the single-address form cannot express .
The health-check problem.
Nginx’s open-source edition does not have active health checks. It has passive health checks: it observes the responses to real requests and marks a backend as failed when it returns errors. The max_fails and fail_timeout parameters control the threshold and the retry interval. Without them, Nginx uses the default of one failure and ten seconds, which may be too aggressive or too lenient for the workload .
a. The basic proxy configuration
The simplest proxy configuration forwards all requests to a single backend. The proxy_pass directive specifies the backend address, and the proxy_set_header directives preserve the client’s information.
# /etc/nginx/conf.d/proxy.conf
server {
listen 80;
server_name 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;
}
}
The proxy_pass http://127.0.0.1:3000; directive forwards the request to the backend on port 3000. The proxy_set_header directives add the headers that the backend needs: the original hostname, the client’s IP, the forwarding chain, and the original scheme . The $proxy_add_x_forwarded_for variable appends the client’s IP to any existing X-Forwarded-For header, which preserves the chain when multiple proxies are involved.
The Host header is the most important. Without it, the backend receives its own hostname, and any absolute URL it generates points to the wrong domain. The X-Forwarded-Proto header is equally important for applications that enforce HTTPS: without it, the application sees an HTTP request and redirects to HTTPS, which the proxy forwards as HTTP to the backend, producing an infinite loop .
b. The upstream block and load balancing
The upstream block defines a pool of backends and the load-balancing algorithm. The proxy_pass directive references the pool by name.
upstream backend {
least_conn;
server 10.0.0.1:3000 weight=3 max_fails=3 fail_timeout=30s;
server 10.0.0.2:3000 max_fails=3 fail_timeout=30s;
server 10.0.0.3:3000 backup;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The least_conn directive selects the backend with the fewest active connections. The weight=3 on the first server gives it three times the share of the requests. The backup on the third server marks it as a standby that receives traffic only when the primary servers are unavailable. The max_fails=3 and fail_timeout=30s parameters mark a backend as failed after three failures within thirty seconds, and retry it after thirty seconds .
The default algorithm is round-robin, which distributes requests evenly in sequence. The ip_hash directive maps the client’s IP address to a backend, providing session persistence for applications that store session state locally . The random directive selects a backend at random, optionally with a weight, which avoids the herding effect that round-robin can produce.
# Round-robin (default)
upstream backend {
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}
# IP hash for session persistence
upstream backend {
ip_hash;
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}
The choice of algorithm depends on the workload. Round-robin is appropriate when the backends have similar capacity and the requests have similar duration. Least-connections is better when the request duration varies, because it avoids sending new requests to a backend that is already busy. IP-hash is a workaround for applications that cannot share session state .
c. TLS termination and the proxy headers
A reverse proxy commonly terminates TLS at the edge and forwards plain HTTP to the backends. The ssl_certificate and ssl_certificate_key directives configure the certificate, and the X-Forwarded-Proto header tells the backend that the original request was HTTPS.
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/nginx/ssl/example.com.crt;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
location / {
proxy_pass http://backend;
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;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
The $scheme variable is https when the request arrives on the TLS listener, so the backend receives X-Forwarded-Proto: https. The application reads this header and knows that the client’s connection was secure, even though the proxy-to-backend connection is plain HTTP . The HTTP-to-HTTPS redirect is a separate server block on port 80 that sends a 301 response.
For applications that use WebSockets, the proxy_http_version 1.1 and proxy_set_header Upgrade $http_upgrade; directives are required. The WebSocket handshake uses the HTTP Upgrade header, and the proxy must forward it to the backend .
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
The Connection "upgrade" header tells the backend that the client is requesting a protocol upgrade. Without these directives, the WebSocket handshake fails, and the client receives a 400 or 502 response.
Complete Example Session
# ============================================
# PART 1: START A TEST BACKEND
# ============================================
python3 -m http.server 3000 --directory /var/www/backend &
# The backend listens on 127.0.0.1:3000
# ============================================
# PART 2: CONFIGURE THE PROXY WITH UPSTREAM
# ============================================
sudo tee /etc/nginx/conf.d/proxy.conf > /dev/null <<'EOF'
upstream backend {
least_conn;
server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
}
server {
listen 80;
server_name proxy.local;
location / {
proxy_pass http://backend;
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;
}
}
EOF
# ============================================
# PART 3: VALIDATE AND RELOAD
# ============================================
sudo nginx -t
sudo systemctl reload nginx
# ============================================
# PART 4: TEST THE PROXY
# ============================================
curl -H "Host: proxy.local" http://localhost
# Output: the content from the backend
# ============================================
# PART 5: VERIFY THE FORWARDING HEADERS
# ============================================
# On the backend, the X-Forwarded-For header contains the client's IP
# The X-Forwarded-Proto header contains "http"
# The Host header contains "proxy.local"
# ============================================
# PART 6: STOP THE BACKEND AND TEST FAILOVER
# ============================================
kill %1
curl -I -H "Host: proxy.local" http://localhost
# Output: 502 Bad Gateway
# ============================================
# PART 7: ADD A SECOND BACKEND
# ============================================
python3 -m http.server 3001 --directory /var/www/backend2 &
# Update the upstream block to include 127.0.0.1:3001
# Reload nginx
# The proxy distributes requests between the two backends
# ============================================
# PART 8: VERIFY LOAD BALANCING
# ============================================
for i in {1..10}; do curl -s -H "Host: proxy.local" http://localhost; done
# The responses alternate between the two backends
# ============================================
# PART 9: TEST THE FORWARDING HEADERS WITH CURL
# ============================================
# Use a backend that echoes the headers, or check the access log
# The X-Forwarded-For header is present with the client's IP
# ============================================
# PART 10: CLEAN UP
# ============================================
kill %1 %2
sudo rm /etc/nginx/conf.d/proxy.conf
sudo systemctl reload nginx
The ten parts demonstrate the complete Nginx reverse proxy setup: a test backend, the proxy configuration with the upstream block, validation, testing, header verification, failover testing, adding a second backend, load balancing verification, and cleanup. The critical verification is Part 5: the backend receives the forwarding headers, and the client’s IP is preserved in X-Forwarded-For.
Quick Reference
Proxy Directives
| Directive | Purpose | Example |
|---|---|---|
proxy_pass | Backend address or upstream name | proxy_pass http://backend; |
proxy_set_header | Set a header on the forwarded request | proxy_set_header Host $host; |
proxy_http_version | HTTP version for the backend connection | proxy_http_version 1.1; |
proxy_next_upstream | Conditions for trying the next backend | proxy_next_upstream error timeout; |
Forwarding Headers
| Header | Nginx Variable | Purpose |
|---|---|---|
Host | $host | Original hostname |
X-Real-IP | $remote_addr | Client’s IP address |
X-Forwarded-For | $proxy_add_x_forwarded_for | Client IP chain |
X-Forwarded-Proto | $scheme | Original protocol |
Upstream Parameters
| Parameter | Purpose | Example |
|---|---|---|
weight=N | Request share | weight=3 |
max_fails=N | Failures before marking unavailable | max_fails=3 |
fail_timeout=Ns | Failure window and retry interval | fail_timeout=30s |
backup | Standby server | backup |
down | Administratively disabled | down |
Load-Balancing Algorithms
| Algorithm | Directive | Use Case |
|---|---|---|
| Round-robin | (default) | Equal backends, similar requests |
| Least-connections | least_conn | Variable request duration |
| IP-hash | ip_hash | Session persistence |
| Random | random | Avoid herding |
Best Practices
✅ Do This:
# Preserve the Host header
proxy_set_header Host $host; # ✅
# Add the forwarding headers
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # ✅
proxy_set_header X-Forwarded-Proto $scheme; # ✅
# Use an upstream block for multiple backends
upstream backend { least_conn; server 10.0.0.1:3000; } # ✅
# Configure health checks
server 10.0.0.1:3000 max_fails=3 fail_timeout=30s; # ✅
# Use WebSocket headers for WebSocket endpoints
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; # ✅
# Validate before reloading
nginx -t # ✅
❌ Don’t Do This:
# Forward without the Host header
proxy_pass http://backend; # Backend sees its own hostname # ❌
# Omit X-Forwarded-Proto
# The application generates HTTP redirects that loop # ❌
# Use a single backend without an upstream block
proxy_pass http://127.0.0.1:3000; # No load balancing # ❌
# Disable health checks
# Failed backends stay in rotation # ❌
# Forget WebSocket headers for WebSocket endpoints
# The handshake fails with a 400 or 502 # ❌
# Reload without validation
systemctl reload nginx # A syntax error prevents reload # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Redirect loop | X-Forwarded-Proto not set | Add the header; configure the app to trust it |
| Wrong client IP in logs | X-Forwarded-For not set | Add the header |
| Wrong hostname in redirects | Host header not preserved | proxy_set_header Host $host; |
| 502 Bad Gateway | Backend down or wrong address | curl the backend directly |
| WebSocket handshake fails | Missing Upgrade headers | Add proxy_http_version 1.1 and the Upgrade headers |
| Uneven load distribution | Round-robin with variable requests | Use least_conn |
Real-World Examples
1. Basic proxy
location / { proxy_pass http://127.0.0.1:3000; }
2. Forwarding headers
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
3. Upstream with least-connections
upstream backend { least_conn; server 10.0.0.1:3000; server 10.0.0.2:3000; }
4. Weighted backend
server 10.0.0.1:3000 weight=3;
5. Backup backend
server 10.0.0.3:3000 backup;
6. Health-check parameters
server 10.0.0.1:3000 max_fails=3 fail_timeout=30s;
7. IP-hash persistence
upstream backend { ip_hash; server 10.0.0.1:3000; }
8. TLS termination
listen 443 ssl; ssl_certificate /path/cert.pem; ssl_certificate_key /path/key.pem;
9. WebSocket proxy
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
10. Validate and reload
nginx -t && systemctl reload nginx
Visual
Nginx Reverse Proxy Architecture
┌──────────────────────────────────────────────────────────────┐
│ CLIENT → NGINX → UPSTREAM POOL → BACKEND │
│ │
│ Client │
│ ┌─────────┐ │
│ │ Browser │ │
│ └────┬────┘ │
│ │ HTTPS │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Nginx (proxy) │ │
│ │ ├── terminates TLS │ │
│ │ ├── selects backend from upstream block │ │
│ │ ├── adds proxy_set_header directives │ │
│ │ └── proxy_pass to the backend │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ HTTP (internal) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ upstream backend { │ │
│ │ least_conn; │ │
│ │ server 10.0.0.1:3000 max_fails=3 fail_timeout=30s; │ │
│ │ server 10.0.0.2:3000; │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Backend 1 │ │ Backend 2 │ │
│ │ :3000 │ │ :3000 │ │
│ └─────────────────┘ └─────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The proxy_set_header Directives
┌──────────────────────────────────────────────────────────────┐
│ EACH HEADER PRESERVES A PIECE OF THE CLIENT'S IDENTITY │
│ │
│ Client request: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ GET / HTTP/1.1 │ │
│ │ Host: example.com │ │
│ │ (from 203.0.113.5) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ proxy_set_header Host $host; │
│ → Backend sees Host: example.com │
│ │
│ proxy_set_header X-Real-IP $remote_addr; │
│ → Backend sees X-Real-IP: 203.0.113.5 │
│ │
│ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;│
│ → Backend sees X-Forwarded-For: 203.0.113.5 │
│ │
│ proxy_set_header X-Forwarded-Proto $scheme; │
│ → Backend sees X-Forwarded-Proto: https │
└──────────────────────────────────────────────────────────────┘
Health Check and Failover
┌──────────────────────────────────────────────────────────────┐
│ PASSIVE HEALTH CHECKS REMOVE FAILED BACKENDS │
│ │
│ upstream backend { │
│ server 10.0.0.1:3000 max_fails=3 fail_timeout=30s; │
│ server 10.0.0.2:3000 max_fails=3 fail_timeout=30s; │
│ } │
│ │
│ Backend 1 returns 3 errors within 30s: │
│ → Marked as failed │
│ → No new requests sent to it for 30s │
│ → After 30s, a single request probes it │
│ ├── Success → returned to rotation │
│ └── Failure → marked failed for another 30s │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Core directive | proxy_pass |
| Host preservation | proxy_set_header Host $host; |
| Client IP | X-Real-IP, X-Forwarded-For |
| Protocol | X-Forwarded-Proto $scheme; |
| Upstream block | Defines the backend pool |
| Default algorithm | Round-robin |
| Least-connections | least_conn |
| Session persistence | ip_hash |
| Health checks | max_fails, fail_timeout |
| TLS termination | listen 443 ssl; + certificate directives |
| WebSocket | proxy_http_version 1.1 + Upgrade headers |
Key takeaways:
- The
proxy_passdirective is the core of the proxy configuration. It forwards the request to the backend address or the upstream pool. Theproxy_set_headerdirectives preserve the client’s identity . - The
Hostheader must be preserved. Without it, the backend sees its own hostname, and any absolute URL it constructs points to the wrong domain.proxy_set_header Host $host;is the standard fix. X-Forwarded-Protoprevents redirect loops. An application that enforces HTTPS based on the request scheme will redirect endlessly if the proxy forwards HTTP without the header. The$schemevariable carries the original protocol .- The
upstreamblock defines the backend pool. It supports weights, backup servers, and health-check parameters that a singleproxy_passcannot express. Theleast_connandip_hashdirectives provide alternatives to round-robin . - Passive health checks remove failed backends. The
max_failsandfail_timeoutparameters control the threshold and the retry interval. A backend that fails is marked unavailable and retried after the timeout . - TLS terminates at the proxy. The
ssl_certificatedirectives configure the certificate, and theX-Forwarded-Protoheader tells the backend that the original request was HTTPS. The backend does not need a certificate . - WebSocket endpoints require the
Upgradeheaders. Theproxy_http_version 1.1andproxy_set_header Upgrade $http_upgrade;directives forward the protocol upgrade handshake .
Remember: Nginx’s reverse proxy configuration is three layers: the upstream block defines the pool, the location block selects the proxy target, and the proxy_set_header directives preserve the client’s identity. The Host and X-Forwarded-Proto headers are not optional; without them, the backend generates the wrong URLs and the wrong redirects. The upstream block provides load balancing and failover that a single proxy_pass cannot. Validate with nginx -t, reload with systemctl reload nginx, and test with curl using the correct Host header.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!