system design

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.

By Akash Sharma·5 min read
#reverse proxy
#nginx
#proxy
#system design
#load balancing
#ssl
#backend
#infrastructure

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.

plaintext
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.

plaintext
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:

nginx
# /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:

nginx
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:

python
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

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

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

nginx
# 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.

yaml
# 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-IP and X-Forwarded-For headers 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.