Reverse Proxy Explained: Nginx, Load Balancing, and More
Learn what a reverse proxy is, how it differs from a forward proxy, and why every production backend uses one. Covers Nginx, SSL termination, and load balancing.
Your app runs on port 8000. You want it available on port 443 (HTTPS). You also want to add SSL, serve static files fast, and distribute traffic across 3 servers.
You don't change your app. You put Nginx in front of it.
That's a reverse proxy.
Forward Proxy vs Reverse Proxy
People mix these up constantly.
Forward proxy: Sits in front of clients. Clients send requests through it. The server sees the proxy's IP, not the client's.
Client → Forward Proxy → Internet → Server
Use cases: VPNs, corporate firewalls, bypassing geo-blocks
The server doesn't know who the real client is.Reverse proxy: Sits in front of servers. Clients send requests to it. It forwards to the right server.
Client → Reverse Proxy → Server 1
→ Server 2
→ Server 3
Use cases: load balancing, SSL termination, caching, routing
The client doesn't know which server actually handled the request.When people say "proxy" in backend contexts, they almost always mean reverse proxy.
What a Reverse Proxy Does
SSL termination: Clients connect over HTTPS. The proxy decrypts the traffic, talks to backend servers over plain HTTP. Certificates live in one place.
Load balancing: Distribute requests across multiple backend instances.
Static file serving: Nginx serves static files (images, CSS, JS) directly from disk — much faster than Python/Node serving them.
Caching: Cache backend responses. Serve repeated requests without hitting your app.
Compression: Gzip responses before sending to clients. Your app doesn't need to.
Security: Hide your app servers from the internet. Clients only see the proxy's IP.
Request routing: /api/* goes to your backend. /static/* served from disk. /docs/* goes to a separate docs service.
Nginx as Reverse Proxy
Nginx is the most common reverse proxy. Here's a real config:
# /etc/nginx/sites-available/myapp
# Upstream backend servers
upstream backend {
server 127.0.0.1:8000;
server 127.0.0.1:8001;
server 127.0.0.1:8002;
}
server {
listen 80;
server_name example.com;
# Redirect HTTP to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
# SSL termination — certs here, not in your app
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Serve static files directly from disk (fast)
location /static/ {
alias /var/www/myapp/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Proxy everything else to backend
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;
}
}This single config handles: HTTPS, HTTP→HTTPS redirect, static files, load balancing across 3 backends, and passes client IP to your app.
Passing the Real Client IP
Your app sees requests from Nginx (127.0.0.1), not the real client. Nginx adds the original IP in headers:
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;Your app reads the right header:
from fastapi import Request
@app.get("/api/data")
def get_data(request: Request):
# Get real client IP
client_ip = request.headers.get("X-Real-IP") or request.client.host
print(f"Request from: {client_ip}")This matters for rate limiting, logging, and geo-based features.
Load Balancing Algorithms in Nginx
# Round robin (default) — requests cycle through servers equally
upstream backend {
server 10.0.0.1:8000;
server 10.0.0.2:8000;
server 10.0.0.3:8000;
}
# Least connections — send to server with fewest active requests
upstream backend {
least_conn;
server 10.0.0.1:8000;
server 10.0.0.2:8000;
}
# IP hash — same client always goes to same server (sticky sessions)
upstream backend {
ip_hash;
server 10.0.0.1:8000;
server 10.0.0.2:8000;
}
# Weighted — server 1 gets 3x more traffic than server 2
upstream backend {
server 10.0.0.1:8000 weight=3;
server 10.0.0.2:8000 weight=1;
}Caching with Nginx
# Cache proxy responses
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:10m max_size=10g;
server {
location /api/public/ {
proxy_cache my_cache;
proxy_cache_valid 200 5m; # Cache 200 responses for 5 minutes
proxy_cache_valid 404 1m; # Cache 404s for 1 minute
proxy_cache_key "$host$request_uri";
# Add cache status header for debugging
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://backend;
}
location /api/user/ {
# Don't cache user-specific endpoints
proxy_no_cache 1;
proxy_pass http://backend;
}
}Rate Limiting at the Proxy Level
# Define rate limit zone — 100 req/min per IP
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/m;
server {
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
# burst=20: allow 20 extra requests before rejecting
# nodelay: reject immediately, don't queue
proxy_pass http://backend;
}
}Rate limiting at the proxy level is more efficient — rejected requests never reach your app.
Traefik: Modern Alternative
Traefik auto-configures from Docker/Kubernetes labels. No manual config files.
# docker-compose.yml
services:
traefik:
image: traefik:v3.0
command:
- "--providers.docker=true"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
ports:
- "443:443"
myapp:
image: myapp:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`example.com`)"
- "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
- "traefik.http.services.myapp.loadbalancer.server.port=8000"Traefik reads the labels, sets up routing + HTTPS automatically. Good for containerized apps.
Key Takeaways
- Reverse proxy sits in front of servers — clients don't talk to your app directly
- Forward proxy sits in front of clients — servers don't see the real client (VPNs)
- Nginx handles SSL termination, load balancing, static files, caching, rate limiting
- Always pass
X-Real-IPandX-Forwarded-Forheaders so your app knows the real client IP - Rate limiting and caching at the proxy level is cheaper — requests die before hitting your app
- Traefik auto-configures from container labels — better for dynamic containerized setups
- Every production backend uses a reverse proxy — it's not optional
The reverse proxy is the front door to your infrastructure. Setting it up right makes everything behind it easier.
Related reading: Load Balancing Strategies · Rate Limiting Your API · API Gateway Pattern
Enjoyed this article?
Get weekly insights on backend architecture, system design, and Go programming.
Related Posts
Continue reading with these related posts
Load Balancing Explained: Algorithms and Strategies
Learn how load balancers distribute traffic across servers. Covers round robin, least connections, consistent hashing, and when to use each.
Kubernetes Explained for Developers: Pods, Services, and Deployments
Learn Kubernetes fundamentals as a developer. Covers pods, deployments, services, ingress, and how to deploy your first app with practical kubectl examples.
API Gateway Pattern: The Front Door to Your Services
Learn what an API gateway does, when to use one, and how to set it up. Covers routing, authentication, rate limiting, and tools like Kong, AWS API Gateway, and Traefik.