system design

Reverse Proxy Explained: How It Works and Why You Need One (2026)

A reverse proxy sits between your users and backend servers — handling SSL termination, load balancing, caching, and DDoS protection. Covers Nginx, Caddy, Traefik, and cloud-native setups.

By Akash Sharma·23 min read
#reverse proxy
#nginx
#system design
#networking
#infrastructure
#load balancing
#ssl
#security
#caddy
#traefik

Your backend service listens on port 8080. It speaks plain HTTP. It has no SSL certificate. It crashes under load. And it exposes your server's IP address directly to the public internet.

A reverse proxy fixes all of this — without touching your application code.

A reverse proxy is a server that sits in front of your backend services, intercepting all incoming requests and forwarding them on behalf of clients. From the client's perspective, it's talking directly to your application. In reality, it's talking to the proxy, which decides where to route the request, optionally caches the response, terminates TLS, and forwards only validated traffic to your backend.

Nginx, Caddy, Traefik, HAProxy, Envoy — these are all reverse proxies. So is Cloudflare, conceptually. They all do the same fundamental thing: intercept, inspect, and forward requests to backend services while handling cross-cutting concerns that would otherwise pollute every service you write.

What Is a Reverse Proxy?

A reverse proxy accepts client connections and proxies them to one or more upstream servers. The client sends its request to the proxy's IP address, not to your application's IP address. The proxy forwards the request to the appropriate backend, receives the response, and returns it to the client.

plaintext
Client


Reverse Proxy (port 443)
  │   ├── terminates TLS
  │   ├── checks rate limits
  │   ├── strips internal headers
  │   └── selects upstream server

  ├──▶ Backend A (port 8080) — app server 1
  ├──▶ Backend B (port 8081) — app server 2
  └──▶ Backend C (port 8082) — static assets / API v2

Your backend servers are never exposed directly to the internet. They live on a private network, reachable only from the proxy. This is the foundational security model for nearly every production web architecture.

What Problem Does It Solve?

Without a reverse proxy, you'd need every backend service to independently handle:

  • TLS certificate acquisition and renewal
  • DDoS and rate limiting
  • Request routing by path or hostname
  • Load balancing across multiple instances
  • Compression (gzip/brotli) for every response
  • Access logging in a consistent format
  • Health checking and failover

A reverse proxy centralizes all of this. Your Node.js app, your Python API, your Go microservice — they all just return HTTP responses. The proxy handles the rest.

Forward Proxy vs Reverse Proxy

These terms confuse people. The distinction is about whose interests the proxy serves.

Forward proxy: Sits in front of clients and acts on their behalf. Clients are configured to route traffic through it. Common uses: corporate internet filtering, anonymizing client identity, caching outbound requests. The server receiving traffic sees the proxy's IP, not the client's.

Reverse proxy: Sits in front of servers and acts on their behalf. Clients don't know it exists — they think they're talking to the application. Common uses: load balancing, SSL termination, DDoS protection. The backend receiving traffic sees the proxy forwarding the request.

plaintext
Forward Proxy (client side):
Client → [Forward Proxy] → Internet → Server

     clients configure this
 
Reverse Proxy (server side):
Client → [Reverse Proxy] → Backend Servers

         servers sit behind this

A VPN or SOCKS proxy is a forward proxy. Nginx in front of your app servers is a reverse proxy. Cloudflare is technically a reverse proxy — you point your DNS to Cloudflare's edge, and it proxies traffic to your origin.

Core Functions of a Reverse Proxy

SSL/TLS Termination

Your backend services speak plain HTTP. The reverse proxy handles TLS — certificate management, TLS handshakes, and cipher negotiation — at the edge, then forwards decrypted HTTP internally.

nginx
# Nginx: terminate TLS at the proxy, forward plain HTTP internally
server {
    listen 443 ssl http2;
    server_name api.example.com;
 
    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
 
    # Only allow TLS 1.2 and 1.3 — no SSLv3, TLSv1.0, TLSv1.1
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers   ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
 
    # HSTS — tell browsers to always use HTTPS for 1 year
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
 
    location / {
        proxy_pass http://127.0.0.1:8080;  # Plain HTTP to 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 api.example.com;
    return 301 https://$host$request_uri;
}

Benefits: Your backend never touches a certificate. When Let's Encrypt renews the cert, you reload Nginx — the app doesn't restart. You configure cipher suites once, in one place, for all backends.

The X-Forwarded-For header is critical: because the backend sees the proxy's IP, not the real client IP, the proxy adds this header so your application can log the actual user IP address and do accurate rate limiting.

Load Balancing

A reverse proxy distributes incoming requests across multiple backend instances. This is how you scale horizontally — add more app servers; the proxy spreads load across them.

nginx
# Nginx upstream block — defines the pool of backend servers
upstream app_servers {
    # Round-robin by default (each request goes to the next server in order)
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
    server 10.0.0.3:8080;
 
    # Or use least_conn — send each request to the server with fewest active connections
    # least_conn;
 
    # Or ip_hash — same client always goes to the same server (sticky sessions)
    # ip_hash;
 
    # Mark a server as backup — only used when all primaries are down
    server 10.0.0.4:8080 backup;
 
    keepalive 32;
}
 
server {
    listen 443 ssl http2;
    location / {
        proxy_pass http://app_servers;
        proxy_next_upstream error timeout http_502 http_503;  # Retry on failure
    }
}

Load balancing algorithms:

AlgorithmHow It WorksBest For
Round-robinRotates through backends sequentiallyStateless apps with homogeneous servers
Least connectionsSends to server with fewest active requestsLong-lived connections (WebSockets)
IP hashSame client IP always → same backendStateful apps requiring sticky sessions
WeightedSome servers get more traffic (e.g., 3:1 ratio)Heterogeneous server capacity
RandomRandom selectionHigh-throughput, short requests

Request Routing

A reverse proxy routes requests to different backends based on URL path or hostname. This is how microservices architecture works in practice — one public URL, multiple backend services.

nginx
# Route by path — monolith vs microservices hybrid
server {
    listen 443 ssl http2;
    server_name example.com;
 
    # API v1 — legacy monolith
    location /api/v1/ {
        proxy_pass http://monolith:8080;
    }
 
    # API v2 — new microservices
    location /api/v2/users/ {
        proxy_pass http://user-service:3001;
    }
 
    location /api/v2/orders/ {
        proxy_pass http://order-service:3002;
    }
 
    location /api/v2/payments/ {
        proxy_pass http://payment-service:3003;
    }
 
    # Static assets — served directly by Nginx or forwarded to a CDN origin
    location /static/ {
        root /var/www;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
 
    # Default — frontend app
    location / {
        proxy_pass http://frontend:3000;
    }
}

Virtual hosting — route by hostname to different apps on the same server:

nginx
# Same IP, different server_name blocks, different backends
server {
    server_name app.example.com;
    location / { proxy_pass http://main-app:8080; }
}
 
server {
    server_name admin.example.com;
    location / { proxy_pass http://admin-app:8081; }
}
 
server {
    server_name api.example.com;
    location / { proxy_pass http://api-service:3000; }
}

Compression

Compressing responses at the proxy means your backend doesn't have to. One configuration covers all services.

nginx
# Nginx gzip compression — enable globally for all proxied responses
gzip on;
gzip_vary on;
gzip_min_length 1024;       # Don't compress tiny responses (overhead > savings)
gzip_proxied any;           # Compress proxied responses too
gzip_comp_level 6;          # 1 (fastest) to 9 (best compression) — 6 is the sweet spot
gzip_types
    text/plain
    text/css
    text/javascript
    application/json
    application/javascript
    application/x-javascript
    application/xml
    image/svg+xml;
# Note: never gzip already-compressed formats (jpeg, png, gif, mp4, zip)

Brotli achieves 15–25% better compression than gzip on text content. Nginx requires the ngx_brotli module (available in mainline nginx from 1.27+, or via nginx-mod-http-brotli):

nginx
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json application/javascript text/xml image/svg+xml;

Caching

A reverse proxy can cache backend responses, reducing the number of requests that reach your application — identical to how a CDN works, but running on your own infrastructure.

nginx
# Define a shared memory zone for cache metadata and a disk path for cache files
proxy_cache_path /var/cache/nginx
    levels=1:2               # Directory nesting (avoids too many files in one dir)
    keys_zone=api_cache:10m  # 10MB for cache keys in shared memory
    max_size=1g              # Max 1GB of cached responses on disk
    inactive=60m             # Remove cached items not accessed for 60 minutes
    use_temp_path=off;
 
server {
    location /api/products {
        proxy_pass         http://backend:8080;
        proxy_cache        api_cache;
        proxy_cache_valid  200 5m;   # Cache 200 responses for 5 minutes
        proxy_cache_valid  404 1m;   # Cache 404s for 1 minute (negative caching)
        proxy_cache_key    "$scheme$request_method$host$request_uri";
        add_header         X-Cache-Status $upstream_cache_status;
    }
}

The X-Cache-Status header lets you verify caching behavior:

  • HIT — served from Nginx cache
  • MISS — fetched from backend
  • EXPIRED — was cached but TTL elapsed
  • BYPASS — cache explicitly bypassed (e.g., Cache-Control: no-cache in request)

Security Headers

Add security headers at the proxy once, rather than in every application.

nginx
server {
    # Remove headers that leak server information
    server_tokens off;
    more_clear_headers Server;  # Requires ngx_headers_more module
 
    # Security headers
    add_header X-Content-Type-Options    "nosniff"                always;
    add_header X-Frame-Options           "DENY"                   always;
    add_header X-XSS-Protection          "1; mode=block"          always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy   "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;
    add_header Permissions-Policy        "geolocation=(), microphone=(), camera=()" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
 
    location / {
        proxy_pass http://backend:8080;
 
        # Strip headers the backend shouldn't be able to spoof
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 
        # Prevent internal headers from being set by clients
        proxy_set_header X-Internal-Secret "";  # Clear any client-sent internal header
    }
}

Nginx: The Standard Reverse Proxy

Nginx is the most widely deployed reverse proxy and web server. It handles millions of concurrent connections with minimal memory because it uses an event-driven, non-blocking architecture — a single Nginx worker can handle tens of thousands of open connections simultaneously.

Nginx Configuration Structure

plaintext
/etc/nginx/
├── nginx.conf          # Main config — worker processes, events block, http block
├── conf.d/             # Include all *.conf files from here
│   ├── default.conf
│   └── api.conf
└── sites-enabled/      # Symlinked from sites-available/ (Debian/Ubuntu pattern)
nginx
# /etc/nginx/nginx.conf — top-level config
worker_processes auto;         # One worker per CPU core
worker_rlimit_nofile 65535;    # Max open files per worker
 
events {
    worker_connections 4096;   # Max connections per worker (total = workers × connections)
    use epoll;                 # Linux event model — most efficient on Linux
    multi_accept on;           # Accept all pending connections at once
}
 
http {
    include       mime.types;
    default_type  application/octet-stream;
 
    # Logging format includes real client IP (from X-Real-IP), not proxy IP
    log_format main '$http_x_real_ip - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent"';
 
    # Performance settings
    sendfile        on;          # Send files directly from disk to socket (zero-copy)
    tcp_nopush      on;          # Send full TCP packets (pairs with sendfile)
    tcp_nodelay     on;          # Disable Nagle algorithm for low-latency responses
    keepalive_timeout 65;        # HTTP keep-alive timeout
 
    # Connection timeouts to backend
    proxy_connect_timeout 5s;    # How long to wait for backend connection
    proxy_send_timeout    60s;   # How long to wait sending request to backend
    proxy_read_timeout    60s;   # How long to wait for backend response
 
    include /etc/nginx/conf.d/*.conf;
}

Production Nginx Proxy Config

nginx
# /etc/nginx/conf.d/api.conf
 
upstream api_backend {
    least_conn;
    server 10.0.1.1:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.2:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.3:8080 max_fails=3 fail_timeout=30s;
    keepalive 64;   # Maintain persistent connections to backends
}
 
server {
    listen 80;
    server_name api.example.com;
    return 301 https://$server_name$request_uri;
}
 
server {
    listen 443 ssl http2;
    server_name api.example.com;
 
    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;
 
    # Rate limiting (define zones in http block)
    limit_req zone=api_limit burst=20 nodelay;
 
    location /api/ {
        proxy_pass http://api_backend;
 
        # Pass real client info to 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;
 
        # Use HTTP/1.1 to backends (enables keep-alive)
        proxy_http_version 1.1;
        proxy_set_header Connection "";
 
        # Buffer settings — balance between memory and latency
        proxy_buffering on;
        proxy_buffer_size        16k;
        proxy_buffers            8 16k;
        proxy_busy_buffers_size  32k;
    }
 
    # Health check endpoint — proxied directly, no rate limiting
    location /health {
        proxy_pass http://api_backend;
        access_log off;  # Don't log health checks
    }
}

Rate Limiting in Nginx

nginx
http {
    # Define shared memory zones for rate limit state
    # 10m = 10MB of shared memory, enough for ~160,000 IP addresses
    limit_req_zone $binary_remote_addr zone=api_limit:10m  rate=100r/m;  # 100 req/min per IP
    limit_req_zone $binary_remote_addr zone=login_limit:5m rate=5r/m;    # 5 req/min for login
 
    server {
        location /api/ {
            limit_req zone=api_limit burst=20 nodelay;
            # burst=20: allow 20 excess requests (queued), then 429
            # nodelay: process burst immediately rather than metering
            limit_req_status 429;
            proxy_pass http://backend;
        }
 
        location /api/auth/login {
            limit_req zone=login_limit burst=3 nodelay;
            limit_req_status 429;
            proxy_pass http://backend;
        }
    }
}

Caddy: Automatic HTTPS

Caddy is a modern reverse proxy written in Go. Its killer feature: automatic HTTPS. Caddy automatically obtains and renews Let's Encrypt certificates for every domain it serves. You never manage a certificate.

plaintext
# Caddyfile — minimal reverse proxy with automatic HTTPS
api.example.com {
    reverse_proxy localhost:8080
}

That's the entire config. Caddy handles DNS-validated HTTPS, HTTP to HTTPS redirects, certificate renewal, and HTTP/2. Compare this to 40+ lines of Nginx SSL configuration.

plaintext
# Caddyfile — production setup with multiple services
api.example.com {
    # Route to different backends by path
    handle /v2/* {
        reverse_proxy user-service:3001 order-service:3002 {
            lb_policy least_conn
            health_uri /health
            health_interval 10s
        }
    }
 
    handle /v1/* {
        reverse_proxy monolith:8080
    }
 
    # Rate limiting (requires caddy-ratelimit plugin)
    rate_limit {
        zone api {
            key {remote_host}
            events 100
            window 1m
        }
    }
 
    # Compression
    encode gzip zstd
 
    # Security headers
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        -Server  # Remove Server header
    }
 
    # Logging
    log {
        output file /var/log/caddy/access.log
        format json
    }
}
 
admin.example.com {
    reverse_proxy admin-app:8081
 
    # Restrict to office IP range
    @blocked not remote_ip 203.0.113.0/24
    respond @blocked 403
}

Caddy is a strong choice for: new projects where simplicity matters, teams without dedicated ops who don't want to manage certificates, and Docker environments where a single container handles TLS.

Traefik: Container-Native Reverse Proxy

Traefik is designed for dynamic container environments. Instead of static config files, Traefik discovers services automatically by watching Docker, Kubernetes, or Consul for container labels and annotations.

yaml
# docker-compose.yml — Traefik with automatic service discovery
version: "3.8"
 
services:
  traefik:
    image: traefik:v3.0
    command:
      - "--providers.docker=true"                    # Watch Docker for services
      - "--providers.docker.exposedbydefault=false"  # Only expose labeled containers
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.le.acme.email=ops@example.com"
      - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.le.acme.tlschallenge=true"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"  # Read Docker events
      - "./letsencrypt:/letsencrypt"
 
  api:
    image: my-api:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`api.example.com`) && PathPrefix(`/api`)"
      - "traefik.http.routers.api.entrypoints=websecure"
      - "traefik.http.routers.api.tls.certresolver=le"
      - "traefik.http.services.api.loadbalancer.server.port=8080"
      - "traefik.http.middlewares.api-ratelimit.ratelimit.average=100"
      - "traefik.http.middlewares.api-ratelimit.ratelimit.burst=20"
 
  frontend:
    image: my-frontend:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.frontend.rule=Host(`example.com`)"
      - "traefik.http.routers.frontend.entrypoints=websecure"
      - "traefik.http.routers.frontend.tls.certresolver=le"
      - "traefik.http.services.frontend.loadbalancer.server.port=3000"

When a new container starts with the traefik.enable=true label, Traefik detects it and starts routing traffic to it — zero config file changes, zero restarts. When the container stops, Traefik stops routing to it.

This makes Traefik the default choice for Docker Compose and Kubernetes setups where services are ephemeral and configs need to be dynamic.

Reverse Proxy vs Load Balancer vs API Gateway

These terms overlap significantly. Understanding where they differ prevents architectural confusion.

ConcernReverse ProxyLoad BalancerAPI Gateway
SSL terminationYesSometimesYes
Request routing (path/host)YesNo (usually L4 only)Yes
Load distributionYes (Layer 7)Yes (L4 or L7)Yes
Authentication / authorizationNoNoYes
Rate limitingYes (basic)NoYes (advanced)
Request/response transformationLimitedNoYes
Service discovery integrationTraefik, yesNoYes
Example productsNginx, Caddy, TraefikAWS NLB, HAProxy L4Kong, AWS API GW, Apigee

Use a reverse proxy when you need SSL, routing, load balancing, and compression. That's most applications.

Use a dedicated load balancer (like AWS Network Load Balancer) when you need pure L4 TCP/UDP load balancing with extreme throughput and minimal latency overhead — e.g., in front of a cluster of reverse proxies.

Use an API gateway when you need authentication enforcement, per-consumer rate limiting, request transformation, developer portal / API key management, or multi-version API management. An API gateway is typically a reverse proxy with business logic baked in.

In a typical production architecture:

plaintext
Internet


AWS NLB (L4, routes TCP to multiple AZs)


Nginx / Traefik cluster (L7, TLS, routing, rate limiting)

    ├──▶ API Gateway (Kong) — authenticated /api/* routes

    └──▶ Backend services — unauthenticated or service-to-service routes

Health Checks and Failover

A reverse proxy only improves availability if it detects and routes around failures. Passive health checks notice failures from real traffic; active health checks probe backends proactively.

nginx
# Nginx passive health checks — built into open-source Nginx
upstream backend {
    server 10.0.1.1:8080 max_fails=3 fail_timeout=30s;
    # If 3 requests fail within 30 seconds, mark server as down for 30 seconds
    # After 30 seconds, try it again with 1 request — if it succeeds, restore it
}

In Traefik, health checks are configured per service:

yaml
labels:
  - "traefik.http.services.api.loadbalancer.healthcheck.path=/health"
  - "traefik.http.services.api.loadbalancer.healthcheck.interval=10s"
  - "traefik.http.services.api.loadbalancer.healthcheck.timeout=3s"

Your backend's /health endpoint should check its own dependencies — database connectivity, cache availability — not just return 200. A server that can't reach the database should fail its health check so the proxy stops sending it traffic.

WebSocket Proxying

HTTP proxying doesn't work for WebSockets out of the box. WebSocket connections start as HTTP and then upgrade — the proxy must understand and support this.

nginx
# Nginx WebSocket configuration
upstream ws_backend {
    server ws-service:8080;
    keepalive 64;
}
 
server {
    location /ws/ {
        proxy_pass http://ws_backend;
 
        # Required for WebSocket upgrade
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";
 
        # Extended timeouts — WebSocket connections are long-lived
        proxy_read_timeout    3600s;  # 1 hour
        proxy_send_timeout    3600s;
 
        proxy_set_header Host            $host;
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

The Upgrade and Connection headers tell both the proxy and the backend to switch protocols. Without these headers, the proxy treats the connection as regular HTTP and the WebSocket handshake fails with a 400 or 101 that the proxy then closes prematurely.

If you're using load balancing with WebSockets, use ip_hash or least_conn (not round-robin) — WebSocket sessions are long-lived connections that should stay pinned to one backend.

Timeouts: The Silent Killer

Incorrect timeout configuration causes two failure modes:

  1. Too short: Legitimate slow requests fail (file uploads, report generation, long queries)
  2. Too long: Failed backends tie up connections, cascading into full connection pool exhaustion
nginx
# Nginx timeout hierarchy — each has a distinct purpose
proxy_connect_timeout 5s;   # Max time to establish connection to backend
                            # If backend doesn't accept in 5s, fail immediately
 
proxy_send_timeout    30s;  # Max time between writes when sending request to backend
                            # Not total request time — time between TCP writes
 
proxy_read_timeout    60s;  # Max time between reads when receiving from backend
                            # Not total response time — time between TCP reads
 
# For long-running operations (file uploads, reports):
location /api/reports/ {
    proxy_read_timeout    300s;   # Allow 5 minutes for report generation
    proxy_send_timeout    120s;   # Allow 2 minutes to send large request body
    client_max_body_size  50m;    # Allow 50MB request body (for file uploads)
    proxy_pass http://backend;
}

A common pattern is a tiered timeout strategy: aggressive short timeouts for regular API endpoints (fail fast), generous timeouts for known slow operations (uploads, reports, batch jobs), and circuit breaker logic at the application layer to stop hammering a slow backend.

Observability

A reverse proxy is the ideal place to generate access logs — it sees every request, regardless of which backend serves it.

nginx
# JSON access log format — compatible with log aggregators (Elasticsearch, Datadog, Grafana Loki)
log_format json_combined escape=json
    '{'
        '"time":"$time_iso8601",'
        '"remote_addr":"$http_x_real_ip",'         # Real client IP (not proxy)
        '"method":"$request_method",'
        '"uri":"$request_uri",'
        '"status":$status,'
        '"bytes_sent":$bytes_sent,'
        '"request_time":$request_time,'             # Total time from request to response
        '"upstream_response_time":"$upstream_response_time",'  # Backend processing time
        '"upstream_addr":"$upstream_addr",'         # Which backend server handled it
        '"http_referer":"$http_referer",'
        '"http_user_agent":"$http_user_agent"'
    '}';
 
access_log /var/log/nginx/access.log json_combined;

$upstream_response_time is particularly valuable: it's the backend's processing time, excluding network time between client and proxy. If $request_time is high but $upstream_response_time is low, the bottleneck is network (client is slow). If both are high, the backend is slow.

Reverse Proxy in Kubernetes

In Kubernetes, the reverse proxy is typically an Ingress Controller — a reverse proxy (usually Nginx or Traefik) that watches Kubernetes Ingress resources and configures itself automatically.

yaml
# Kubernetes Ingress — declarative routing config
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    nginx.ingress.kubernetes.io/limit-rps: "100"       # Rate limiting annotation
    nginx.ingress.kubernetes.io/proxy-body-size: "50m"  # Max request body
    cert-manager.io/cluster-issuer: "letsencrypt-prod"  # Automatic TLS via cert-manager
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - api.example.com
      secretName: api-tls-secret  # cert-manager populates this
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /api/v2/users
            pathType: Prefix
            backend:
              service:
                name: user-service
                port:
                  number: 80
          - path: /api/v2/orders
            pathType: Prefix
            backend:
              service:
                name: order-service
                port:
                  number: 80
          - path: /
            pathType: Prefix
            backend:
              service:
                name: frontend
                port:
                  number: 3000

The Nginx Ingress Controller watches for changes to Ingress resources and automatically updates its routing configuration. Add a new microservice: create an Ingress resource; traffic starts routing within seconds, with TLS handled automatically by cert-manager.

Frequently Asked Questions

What is the difference between a reverse proxy and a load balancer?

A reverse proxy operates at Layer 7 (HTTP) and can make routing decisions based on URLs, headers, and request content. It handles SSL termination, compression, caching, and request routing to multiple different backend services. A load balancer in the traditional sense operates at Layer 4 (TCP/UDP) and distributes connections across identical servers based purely on network-level information — it doesn't inspect HTTP content. In practice, modern tools blur this: Nginx is a reverse proxy that also load balances, while AWS ALB is a load balancer that also proxies HTTP. The key distinction is Layer 4 (connection distribution) vs Layer 7 (HTTP-aware routing).

Why use Nginx instead of letting each service handle its own SSL?

SSL termination at the proxy centralizes certificate management, cipher suite configuration, and TLS version enforcement in one place. If you have 10 backend services and manage certificates individually, you have 10 places to update when a cipher is deprecated, 10 renewal schedules to track, and 10 configurations to audit. The proxy does it once. Backend services speak plain HTTP on a private network, which is simpler and faster (no TLS overhead on internal connections). It also lets you configure HTTP/2 once at the proxy — backends can remain on HTTP/1.1 internally.

What is the difference between a reverse proxy and an API gateway?

A reverse proxy handles infrastructure concerns: TLS termination, routing, load balancing, compression, and caching. It doesn't understand your application's business logic. An API gateway adds application-layer concerns on top: authentication enforcement (validate JWT tokens, API keys), per-consumer rate limiting (different limits per client), request transformation (add/remove headers, rewrite request bodies), response transformation, protocol translation (REST to gRPC), and developer portal features (API key issuance, documentation). Many organizations use both: Nginx as the ingress point, Kong or AWS API Gateway behind it for API-specific concerns.

Does a reverse proxy hide my backend IP address?

Yes. If configured correctly, client connections terminate at the proxy. Your backend servers are on a private network with no public IP address, or have firewall rules blocking all inbound traffic except from the proxy's IP. An attacker scanning the internet sees only the proxy's IP. This is a critical security benefit — direct attacks on your application servers are impossible if they're unreachable from the public internet. Combine this with restricting SSH access to a bastion host and you've eliminated most network-level attack surface.

Should I use Nginx, Caddy, or Traefik?

Nginx: Most battle-tested, widest ecosystem, best for high-traffic production workloads where you need fine-grained tuning. Steeper config learning curve. Default choice for bare-metal and VM deployments.

Caddy: Best for teams who want zero-config HTTPS. Automatically provisions and renews Let's Encrypt certificates. Simpler config syntax. Excellent for new projects and smaller teams. Go-native, easy to extend with plugins.

Traefik: Best for Docker and Kubernetes environments. Auto-discovers services from container labels and Kubernetes annotations without config file changes. Built-in dashboard for observability. Less performant than Nginx under extreme load, but fine for most workloads.

How does a reverse proxy improve security?

A reverse proxy improves security in several ways: (1) It hides backend server IPs, preventing direct attacks. (2) It strips internal headers that clients shouldn't be able to set (like X-Internal-Role). (3) It adds security response headers (CSP, HSTS, X-Frame-Options) uniformly across all services. (4) It enables rate limiting to slow brute-force and DDoS attacks. (5) It terminates TLS, ensuring clients can't negotiate weak cipher suites even if a backend misconfigures it. (6) It centralizes access logging, giving you a single audit trail for all requests. None of these require changes to backend code.

What is X-Forwarded-For and why does it matter?

When a reverse proxy forwards a request to the backend, the backend sees the proxy's IP address as the "remote address" — not the real client's IP. X-Forwarded-For is a header the proxy adds to pass along the original client IP. Without it, your application can't do per-IP rate limiting, geolocation, fraud detection, or accurate logging. Your application must trust and read this header, but only from known proxy IPs — if clients can send their own X-Forwarded-For header and your app trusts it, they can spoof their IP address. In Nginx, proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for appends the real IP to any existing forwarded chain, preserving multi-hop tracing.


Key Takeaways

  • A reverse proxy intercepts all client traffic before it reaches backend servers — centralizing SSL, routing, load balancing, compression, caching, and security headers
  • SSL termination at the proxy means backend services speak plain HTTP on private networks; certificate management is centralized in one place
  • Nginx is the standard choice for high-traffic production; Caddy for zero-config HTTPS; Traefik for container-native auto-discovery
  • The X-Forwarded-For header is essential — without it, backends see the proxy's IP, not the real client IP, breaking rate limiting and logging
  • Load balancing at Layer 7 (HTTP) lets you route by URL path, hostname, or header — not just distribute connections round-robin
  • Health checks (passive or active) enable automatic failover; without them, the proxy sends requests to dead backends
  • WebSocket proxying requires explicit Upgrade and Connection headers; without them, the upgrade handshake fails
  • A reverse proxy is the natural place for access logging — it sees every request across all services in a consistent format

Related reading: Load Balancing Strategies · CDN Explained · Rate Limiting Your API · Service Discovery Explained

Enjoyed this article?

Get weekly insights on backend architecture, system design, and Go programming.