Reverse Proxy Concepts #
Before diving into configuration, it’s important to understand what actually happens when Nginx works as a reverse proxy. This conceptual understanding will make all the subsequent configuration — proxy_pass, headers, buffering, caching — much more sensible and make debugging easier when problems arise.
Forward Proxy vs Reverse Proxy #
The word “proxy” means intermediary. What distinguishes a forward proxy from a reverse proxy is who it acts as an intermediary for.
Forward Proxy: Works for the Client #
A forward proxy is an intermediary standing on the client side. The client configures its browser or application to send all requests through the proxy. The destination server sees the proxy’s IP, not the client’s real IP.
Client → [Forward Proxy] → Internet → Server
(the client knows there's a proxy)
(the server doesn't know the client's real IP)
Use cases: corporate VPNs, content filtering, bypassing geo-restrictions, anonymization.
Reverse Proxy: Works for the Server #
A reverse proxy is an intermediary standing on the server side. The client sends requests to the proxy without knowing there’s another server behind it. The proxy forwards requests to the right server and returns its response.
Client → Internet → [Nginx Reverse Proxy] → Application Server
(the client doesn't know there's a backend)
(the backend knows there's a proxy)
From the browser’s perspective, Nginx as a reverse proxy looks like the final server — the browser only communicates with Nginx on port 443, never directly with the Node.js on port 3000 or Python on port 8000 behind it.
flowchart LR
subgraph CLIENT["Client Side"]
B["Browser"]
end
subgraph NGINX["Reverse Proxy"]
N["Nginx\nPort 443 HTTPS"]
end
subgraph BACKEND["Server Side — not exposed to the internet"]
A1["Node.js App\nPort 3000"]
A2["Python API\nPort 8000"]
A3["Static Files\nDisk"]
end
B -- "HTTPS request" --> N
N -- "Internal HTTP" --> A1
N -- "Internal HTTP" --> A2
N -- "read file" --> A3Why Almost Every Production Application Uses a Reverse Proxy #
Putting Nginx in front of an application server became an industry standard for good reason. There are real benefits that aren’t easy to get without it:
1. SSL/TLS Termination #
Nginx handles all the complexity of HTTPS encryption/decryption. The backend receives plain HTTP without needing to manage certificates, TLS handshakes, or cryptographic overhead. Nginx is highly optimized for this — using hardware acceleration when available, managing session caches, and supporting TLS 1.3 efficiently.
Browser ──HTTPS──► Nginx ──HTTP──► Node.js
(Nginx handles TLS) (doesn't need to handle TLS)
2. Much More Efficient Static File Serving #
Nginx uses sendfile() — a system call that transfers files directly from disk to the network socket without passing through user space. Application frameworks like Express.js or Django are far slower at this because data must pass through the language runtime.
Nginx serving static: Disk → Kernel → Network (zero-copy)
Node.js serving static: Disk → Kernel → Node.js (user space) → Kernel → Network
3. Protecting Application Ports #
Application servers run on non-standard ports (3000, 8000, 8080) that don’t need to be exposed to the internet. Only Nginx’s ports 80/443 open outward. This simplifies firewall rules and reduces the attack surface.
4. Buffering for Slow Connections #
Nginx receives the entire request from slow clients (slow client attacks) fully before forwarding to the backend. Without this, a single client with a slow connection could hold a thread on the application server for seconds — consuming resources meant for other clients.
5. One Entry Point for Many Services #
Path-based routing allows many services to run behind one domain:
example.com/ → React SPA (static files served directly by Nginx)
example.com/api/ → Node.js REST API (port 3000)
example.com/admin/ → Python Django (port 8000)
example.com/ws/ → WebSocket server (port 4000)
example.com/files/ → Dedicated file storage service (port 9000)
From the client’s perspective: one domain, one port, one SSL certificate. Behind it there can be dozens of different microservices.
6. Rate Limiting and Access Control #
Nginx can limit requests per IP, per endpoint, or based on certain conditions before the request ever touches the backend. This is far more efficient than doing rate limiting at the application level.
The Request Flow in a Reverse Proxy: Two Separate Connections #
Understanding this flow in detail is crucial for debugging. Nginx makes two completely separate connections: one to the client, one to the backend.
sequenceDiagram
participant B as Browser
participant N as Nginx
participant A as Node.js App
Note over B,N: Connection 1 (TCP + TLS to port 443)
B->>N: GET /api/users HTTP/1.1\nHost: example.com\nCookie: session=abc123
Note over N: Nginx checks the config:\nlocation /api/ → proxy_pass http://localhost:3000
Note over N,A: Connection 2 (TCP to port 3000, can be keepalive)
N->>A: GET /api/users HTTP/1.1\nHost: example.com\nX-Real-IP: 203.0.113.1\nX-Forwarded-For: 203.0.113.1\nX-Forwarded-Proto: https
A->>N: HTTP/1.1 200 OK\nContent-Type: application/json\n[JSON body]
Note over N: Nginx receives the response,\nbuffers if needed,\npotentially caches
N->>B: HTTP/2 200 OK\nContent-Type: application/json\n[JSON body]
Note over N: Log to access logThese two separate connections have important implications:
- Two sets of timeouts — the timeout to the client and the timeout to the backend are configured separately
- Nginx can use different protocols — HTTPS to the client but HTTP to the backend, or HTTP/2 to the client but HTTP/1.1 to the backend
- Nginx can modify requests and responses before forwarding them
What Nginx Does to Requests #
When forwarding a request to the backend, Nginx makes several modifications by default:
What Gets Removed #
Headers REMOVED by default:
Connection — hop-by-hop header, irrelevant to the next hop
Keep-Alive — hop-by-hop header
Upgrade — hop-by-hop header (needs to be re-added for WebSocket)
Headers with names containing underscores (e.g., X-Custom_Header)
What Gets Changed #
Headers CHANGED:
Host → changed to the backend's hostname (not the client's original domain)
example: from "example.com" to "localhost" or "10.0.0.1"
What Gets Forwarded As-Is #
All other request headers are forwarded as-is, including Cookie, Authorization, Content-Type, Accept, User-Agent, etc.
Implications for the Backend #
Because of the modifications above, the backend by default doesn’t know:
- The client’s real IP — what’s visible is Nginx’s IP (127.0.0.1 or an internal IP)
- The domain being accessed — because the
Hostheader was changed - Whether the original connection was HTTPS or HTTP — because Nginx terminates TLS
This is why you need to add headers explicitly:
location /api/ {
proxy_pass http://localhost:3000;
# Original client information that needs to reach the backend
proxy_set_header Host $host; # original domain
proxy_set_header X-Real-IP $remote_addr; # client's real IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # IP chain
proxy_set_header X-Forwarded-Proto $scheme; # http or https
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
How Backends Read the X-Forwarded Headers #
Because Nginx sends client information through the X-Forwarded-* headers, every application framework needs to be configured to read those headers. Without this configuration, the framework will still see Nginx’s IP as the client IP.
Express.js (Node.js) #
// Trust the proxy in front of Express
app.set('trust proxy', 1);
// After this, req.ip will contain the client's real IP from X-Real-IP
// req.protocol will contain 'https' if X-Forwarded-Proto: https
console.log(req.ip); // client's real IP
console.log(req.protocol); // 'https'
Django (Python) #
# settings.py
# List of trusted proxy IPs
ALLOWED_HOSTS = ['example.com']
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Django will use HTTP_X_FORWARDED_FOR for request.META['REMOTE_ADDR']
Laravel (PHP) #
// app/Http/Middleware/TrustProxies.php
protected $proxies = '*'; // or a specific Nginx IP
protected $headers = Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO;
Multi-Layer Architecture: Nginx in Front of Nginx #
In more complex environments, you can have several Nginx layers:
flowchart TD
B["Browser"] --> CDN["CDN\nCloudflare / Akamai"]
CDN --> LB["Nginx Load Balancer\nPublic IP\nTLS Termination"]
LB --> N1["Nginx Instance 1\n(Web Server + Reverse Proxy)"]
LB --> N2["Nginx Instance 2\n(Web Server + Reverse Proxy)"]
N1 --> APP["App Servers\nNode.js / Python / PHP"]
N2 --> APPIn multi-layer architectures, a common problem is X-Forwarded-For containing an IP chain — the original client plus several proxy IPs. Important configuration:
# At the inner Nginx layer (the one directly facing the backend):
# Trust the X-Forwarded-For sent by the outer Nginx layer
set_real_ip_from 10.0.0.0/8; # Nginx load balancer IP range
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on; # Recursive to find the real IP
# After set_real_ip_from, $remote_addr will contain the client's real IP
proxy_set_header X-Real-IP $remote_addr;
What to Prepare Before Configuring a Reverse Proxy #
Before you start writing proxy_pass configuration, there are a few things you need to make sure of:
# 1. Make sure the backend is running and reachable from Nginx
curl http://localhost:3000/health
# Should get a response, not "Connection refused"
# 2. Check the port the backend uses
ss -tlnp | grep 3000 # Or: netstat -tlnp | grep 3000
# 3. Make sure Nginx can resolve the backend hostname (if using a hostname)
nginx -t # Validate the configuration first
# 4. Note the user running Nginx
ps aux | grep nginx | grep worker
# Usually: www-data (Ubuntu) or nginx (CentOS)
# This user is the one making connections to the backend
# 5. If the backend is on another server, check the firewall
# The backend port must be reachable from the Nginx server's IP
telnet 10.0.0.2 3000
Nginx Reverse Proxy vs CDN #
A question that often comes up: if a CDN (Cloudflare, Akamai, CloudFront) already works as a reverse proxy, why still need Nginx?
The short answer: they’re complementary, not alternatives.
| Aspect | Nginx Reverse Proxy | CDN |
|---|---|---|
| Location | On our own server | Distributed globally (edge nodes) |
| Latency | After the request reaches our data center | Served from the edge closest to the client |
| Control | Full — we control all configuration | Limited to the features the CDN provides |
| SSL Termination | On our server | At the CDN edge |
| Backend Routing | Very flexible (path, header, regex) | Limited |
| Custom Logic | Very flexible (Lua, modules) | Limited |
| Cost | Just our server | Cost per bandwidth/request |
| Cache | On our server, close to the backend | At the global edge |
# Common architecture: CDN + Nginx working together
# Client → CDN (Cloudflare) → Nginx → Backend
# At Nginx: trust IPs from the CDN
# (so $remote_addr contains the client's real IP, not Cloudflare's IP)
set_real_ip_from 103.21.244.0/22; # Cloudflare IP range
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
# Cloudflare IPv6
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
real_ip_header CF-Connecting-IP; # Cloudflare header containing the client's real IP
Backend Health Checks #
Before a reverse proxy can forward requests, you need to know whether the backend is healthy. Nginx open source supports passive health checks — it detects problematic backends based on error responses.
upstream app_backend {
server 10.0.0.1:3000;
server 10.0.0.2:3000;
server 10.0.0.3:3000;
# Passive health check:
# If a backend returns errors 3x within 30 seconds,
# mark it "down" for 30 seconds
# (during that time requests aren't sent to this backend)
# the server is tried again after 30 seconds
}
server {
location / {
proxy_pass http://app_backend;
# Retry another backend if the first one errors
proxy_next_upstream error timeout http_500 http_502 http_503;
proxy_next_upstream_tries 3; # Maximum 3 retries
proxy_next_upstream_timeout 10s; # Total time for all retries
}
}
Nginx Plus supports active health checks — Nginx proactively sends health check requests to backends without waiting for a failed client request. For Nginx open source, this can be achieved with the ngx_upstream_check_module module or by relying on passive health checks.
# Common health check endpoints in backends:
# GET /health
# GET /healthz (Kubernetes-style)
# GET /api/health
# GET /_health
# Expected response:
# HTTP 200 OK with body: {"status": "ok"}
# or just HTTP 200 without a body
Logging for Reverse Proxies #
Nginx logs record requests from Nginx’s side — not the backend’s side. For effective reverse proxy debugging, the log format needs to include backend information:
http {
log_format proxy_detailed
'$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'$upstream_addr ' # selected backend IP
'$upstream_status ' # status from the backend (200, 500, etc.)
'$upstream_response_time ' # time the backend took to respond
'$request_time ' # total request time (including sending to client)
'$upstream_cache_status'; # HIT/MISS/BYPASS (if using cache)
server {
access_log /var/log/nginx/myapp-access.log proxy_detailed;
location / {
proxy_pass http://backend;
}
}
}
With this format, you can answer critical questions:
$upstream_response_timemuch smaller than$request_time→ the client is slow to download$upstream_response_timelarge → the backend is slow$upstream_statusis 500 but$statusis 200 → Nginx is hiding backend errors (checkproxy_intercept_errors)
# Log analysis: which endpoint is the slowest?
awk '{print $NF, $(NF-2)}' /var/log/nginx/access.log | sort -n | tail -20
# Analysis: which backend errors often?
awk '{print $(NF-3), $(NF-2)}' /var/log/nginx/access.log | grep "500\|502\|503" | sort | uniq -c
# Average response time per endpoint
awk '{print $7, $(NF-2)}' /var/log/nginx/access.log | awk '{sum[$1]+=$2; count[$1]++} END {for(k in sum) print k, sum[k]/count[k]}' | sort -k2 -n
Summary #
- A reverse proxy stands on the server side — clients communicate only with Nginx, never directly with the backend. This provides benefits: SSL termination, efficient static file serving, port protection, buffering, and centralized routing.
- Two separate connections: Nginx creates connections to the client and to the backend independently — protocols, timeouts, and headers can differ on both sides.
- By default Nginx removes some headers (Connection, hop-by-hop) and changes Host when forwarding requests to the backend.
- The backend doesn’t automatically know the client’s real IP — it needs explicit
X-Real-IP,X-Forwarded-For,X-Forwarded-Protoheaders.- Application frameworks need to be configured to read the X-Forwarded-* headers so
request.ipandrequest.protocolshow the real values, not Nginx’s IP.