| |

LFCE 57 🐧 Reverse Proxy with Nginx

⏱️ Estimated read: 13 min | |

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

DirectivePurposeExample
proxy_passBackend address or upstream nameproxy_pass http://backend;
proxy_set_headerSet a header on the forwarded requestproxy_set_header Host $host;
proxy_http_versionHTTP version for the backend connectionproxy_http_version 1.1;
proxy_next_upstreamConditions for trying the next backendproxy_next_upstream error timeout;

Forwarding Headers

HeaderNginx VariablePurpose
Host$hostOriginal hostname
X-Real-IP$remote_addrClient’s IP address
X-Forwarded-For$proxy_add_x_forwarded_forClient IP chain
X-Forwarded-Proto$schemeOriginal protocol

Upstream Parameters

ParameterPurposeExample
weight=NRequest shareweight=3
max_fails=NFailures before marking unavailablemax_fails=3
fail_timeout=NsFailure window and retry intervalfail_timeout=30s
backupStandby serverbackup
downAdministratively disableddown

Load-Balancing Algorithms

AlgorithmDirectiveUse Case
Round-robin(default)Equal backends, similar requests
Least-connectionsleast_connVariable request duration
IP-haship_hashSession persistence
RandomrandomAvoid 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

PitfallWhy It HappensFix
Redirect loopX-Forwarded-Proto not setAdd the header; configure the app to trust it
Wrong client IP in logsX-Forwarded-For not setAdd the header
Wrong hostname in redirectsHost header not preservedproxy_set_header Host $host;
502 Bad GatewayBackend down or wrong addresscurl the backend directly
WebSocket handshake failsMissing Upgrade headersAdd proxy_http_version 1.1 and the Upgrade headers
Uneven load distributionRound-robin with variable requestsUse 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

ItemValue
Core directiveproxy_pass
Host preservationproxy_set_header Host $host;
Client IPX-Real-IP, X-Forwarded-For
ProtocolX-Forwarded-Proto $scheme;
Upstream blockDefines the backend pool
Default algorithmRound-robin
Least-connectionsleast_conn
Session persistenceip_hash
Health checksmax_fails, fail_timeout
TLS terminationlisten 443 ssl; + certificate directives
WebSocketproxy_http_version 1.1 + Upgrade headers

Key takeaways:

  • The proxy_pass directive is the core of the proxy configuration. It forwards the request to the backend address or the upstream pool. The proxy_set_header directives preserve the client’s identity .
  • The Host header 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-Proto prevents redirect loops. An application that enforces HTTPS based on the request scheme will redirect endlessly if the proxy forwards HTTP without the header. The $scheme variable carries the original protocol .
  • The upstream block defines the backend pool. It supports weights, backup servers, and health-check parameters that a single proxy_pass cannot express. The least_conn and ip_hash directives provide alternatives to round-robin .
  • Passive health checks remove failed backends. The max_fails and fail_timeout parameters 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_certificate directives configure the certificate, and the X-Forwarded-Proto header tells the backend that the original request was HTTPS. The backend does not need a certificate .
  • WebSocket endpoints require the Upgrade headers. The proxy_http_version 1.1 and proxy_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.


Was this helpful?


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!