Guide

Reverse proxy explained

Every production web stack has a gatekeeper between the public internet and your application servers. That gatekeeper is usually a reverse proxy — nginx, Caddy, HAProxy, or Envoy — sitting on port 443, terminating TLS, routing requests to the right upstream, and shielding backends from direct exposure. Unlike a forward proxy (which hides clients from servers), a reverse proxy hides servers from clients. This guide explains what reverse proxies actually do, how path-based routing and upstream pools work, WebSocket upgrades, header injection, and the production mistakes that cause mysterious 502 errors and broken real-time features.

Forward proxy vs reverse proxy

The direction of traffic defines the role:

  • Forward proxy — sits in front of clients. A corporate network routes employee browsers through Squid so IT can filter outbound traffic. The origin server never sees the employee's IP; it sees the proxy.
  • Reverse proxy — sits in front of servers. Visitors connect to solana.garden; nginx on the VPS accepts the connection and forwards it to a Node process on 127.0.0.1:3847. The visitor never talks to the backend directly.

Reverse proxies solve several problems at once: a single public IP and certificate for many internal services, centralized TLS management, request filtering, and a stable hostname even when you replace or scale backends behind it. They are the natural companion to load balancers — in fact, most L7 load balancers are reverse proxies with distribution logic layered on top.

What a reverse proxy does on every request

A typical HTTPS request passes through these stages:

  1. Accept the TCP connection on port 443 (or 80, then redirect to HTTPS).
  2. Complete the TLS handshake using the server's certificate — decrypting the request body and headers.
  3. Match a routing rule — usually by Host header and URL path — to select an upstream pool.
  4. Optionally transform the request — add X-Forwarded-For, strip internal headers, enforce rate limits, or reject oversized bodies.
  5. Open or reuse a connection to the upstream — often HTTP/1.1 keep-alive to a local app server or another container in the same host.
  6. Stream the response back — optionally compress, cache, or inject security headers before re-encrypting for the client.

The application server sees a request that looks like it came from 127.0.0.1 or the proxy's internal IP, not the end user's address. That is why frameworks read X-Forwarded-For or X-Real-IP to recover the client IP for logging and rate limiting — and why trusting those headers without validating they came from your proxy is a security hole (attackers can spoof them if the app is reachable directly).

TLS termination

Terminating TLS at the proxy means the proxy holds the certificate (from Let's Encrypt, a commercial CA, or an internal PKI) and speaks plain HTTP to backends on a private network. Benefits:

  • Centralized cert renewal — certbot or Caddy's automatic ACME runs once at the edge; individual app containers do not each need certificates.
  • CPU offload — TLS handshakes are expensive; dedicated proxy hardware or well-tuned nginx handles them so Node/Python workers focus on app logic.
  • Protocol control — enforce TLS 1.2+, modern cipher suites, and HSTS in one config file.

The tradeoff is that traffic between proxy and backend is unencrypted unless you configure TLS passthrough (proxy forwards encrypted bytes without decrypting) or re-encryption (proxy terminates client TLS, then opens a new TLS connection upstream). For services on the same machine or VPC, plain HTTP on localhost is standard. For cross-AZ backend communication in regulated environments, re-encrypt or use a service mesh like Istio that handles mTLS between pods automatically.

Path-based routing and virtual hosts

A single reverse proxy commonly fronts multiple applications using two mechanisms:

Virtual hosts (server_name)

Route by the HTTP Host header. api.example.com goes to the API fleet; www.example.com goes to the marketing static site; admin.example.com goes to an internal dashboard. Each virtual host block can have its own TLS certificate (SNI lets nginx present the right cert per hostname on a shared IP).

Path prefixes (location blocks)

Route by URL path on the same hostname. A typical pattern:

  • / — static files or SSR frontend
  • /api/ — Node or Go API on port 3000
  • /ws/ — WebSocket server on port 3001
  • /rpc/ — proxied Solana RPC with rate limiting (never expose your API key in client-side code — proxy it server-side)

Order matters in nginx: more specific location blocks must win over generic prefix matches. A common bug is a catch-all location / that swallows /api before the API rule is evaluated — symptoms include HTML error pages on JSON endpoints and CORS failures that are actually 404s from the wrong upstream.

Upstream pools and health

When multiple backend instances serve the same role, the reverse proxy maintains an upstream block — a named group of backend addresses with a load-balancing method (round robin, least connections, ip_hash for sticky sessions). This is the same concept covered in depth in our load balancing guide, but implemented in proxy config rather than cloud-managed hardware.

Production upstreams need health-aware routing. nginx's max_fails and fail_timeout temporarily remove backends that return errors; HAProxy and Envoy offer richer active health checks (HTTP GET to /healthz every N seconds). Without health checks, a crashed pod still receives traffic until an operator notices — users see intermittent 502 Bad Gateway responses while the proxy retries a dead socket.

Connection pooling between proxy and upstream reduces latency. Enable keepalive upstream connections so the proxy reuses TCP sockets instead of opening a new three-way handshake per request. Under load, missing keepalive can exhaust ephemeral ports and file descriptors on the proxy host.

WebSocket and SSE upgrades

HTTP/1.1 connections can upgrade to WebSockets via the Upgrade: websocket handshake. A reverse proxy must forward Upgrade and Connection headers and disable response buffering for that location — otherwise the proxy buffers the first frames and the connection never establishes. nginx requires explicit directives:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

Long-lived WebSocket connections also need generous read timeouts. A default 60-second proxy timeout drops idle chat or game connections even when both ends are healthy. For WebSockets and SSE, set timeouts to match your application's heartbeat interval plus margin, or use dedicated L4 passthrough for extremely chatty real-time workloads.

Security and policy at the edge

Centralizing traffic at the proxy is the right place for cross-cutting security:

  • Rate limiting — nginx limit_req_zone or Envoy rate-limit filters enforce per-IP quotas before requests hit your app. Pair with our API rate limiting guide for token-bucket semantics and 429 responses.
  • Request size limitsclient_max_body_size blocks oversized uploads that would OOM a parser.
  • IP allowlists — restrict /admin to office CIDRs.
  • Security headers — inject CSP, X-Frame-Options, and Referrer-Policy once for all backends.
  • WAF rules — cloud CDNs and managed load balancers add SQLi and XSS pattern blocking at the edge.

Block direct access to backend ports at the firewall. If port 3000 is reachable from the internet, attackers bypass every proxy policy. Only the proxy's 443 should be public; backends bind to 127.0.0.1 or a private VPC subnet.

Caching, compression, and static files

Reverse proxies can serve fingerprinted static assets directly from disk (root /var/www/dist;) without involving an app server — faster and cheaper. For dynamic responses, proxy_cache stores upstream responses keyed by URL (and optionally Vary headers), cooperating with HTTP cache headers your app emits. Misconfigured proxy caching of authenticated API responses is a classic data-leak bug: never cache responses that carry Authorization or Set-Cookie unless you explicitly design for it.

gzip and brotli compression at the proxy reduces bytes on the wire. Compress text (HTML, JSON, JS, CSS); do not compress already-compressed images or video. A CDN is essentially a distributed reverse proxy cache at the network edge — same concepts, more PoPs.

Example nginx configuration

The following minimal config illustrates the concepts above — one hostname, static files at the root, API on a local Node process, and WebSocket support. Real configs add logging, rate limits, and multiple environments; treat this as a readable skeleton:

upstream api_backend {
    server 127.0.0.1:3000;
    keepalive 32;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Security headers (tune CSP for your app)
    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options nosniff always;

    # Static site — try file, else SPA fallback
    location / {
        root /var/www/example/dist;
        try_files $uri $uri/ /index.html;
    }

    # API — forward client metadata
    location /api/ {
        proxy_pass http://api_backend;
        proxy_http_version 1.1;
        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;
        proxy_set_header Connection "";
    }

    # WebSocket — upgrade headers + long timeout
    location /ws/ {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }
}

Note how proxy_pass with a trailing slash on the location but not on the upstream URL rewrites paths — a frequent source of double-slash or missing-prefix bugs. Test routing with curl -v https://example.com/api/health and confirm the upstream receives the path you expect before shipping to production.

For observability, enable structured access logs and forward them to your metrics and tracing stack. Log fields like $upstream_status, $upstream_response_time, and $request_time distinguish slow clients from slow backends — essential when debugging latency that users blame on your API but originates at the proxy layer.

Popular reverse proxy software

  • nginx — battle-tested, event-driven, huge ecosystem. Config is declarative but has sharp edges (location precedence, buffering defaults). Dominant for static + reverse proxy on VPS deployments.
  • Caddy — automatic HTTPS via ACME, simpler config for small teams. Good default for single-server deployments and internal tools.
  • HAProxy — exceptional L4/L7 load balancing and health checks; often paired with nginx (HAProxy distributes, nginx serves static).
  • Envoy — cloud-native proxy with dynamic config, observability hooks, and gRPC awareness. Powers many service meshes and API gateways.
  • Traefik / Ingress controllers — in Kubernetes, Ingress resources define routing rules; the controller (nginx-ingress, Traefik, AWS ALB Ingress) materializes them as live proxy config as pods scale.

For local development, Docker Compose often puts nginx or Traefik in front of multiple containers so you mirror production routing on localhost.

Common pitfalls

  • 502 Bad Gateway — upstream is down, wrong port, or firewall blocked. Check proxy error logs and confirm the backend listens on the expected interface (0.0.0.0 vs 127.0.0.1).
  • Wrong scheme in redirects — app generates http:// links because it does not know the client used HTTPS. Set X-Forwarded-Proto https and configure the framework's trusted proxy list.
  • Buffering breaks streaming — SSE and large downloads need proxy_buffering off or chunked responses stall until the buffer fills.
  • Trusting X-Forwarded-For from anyone — only honor forwarded headers from your proxy's IP range.
  • Missing WebSocket headers — real-time features work locally, fail in production behind nginx.
  • Config not reloadednginx -t then nginx -s reload; a syntax error in a bad deploy can drop all sites on that host.
  • Single proxy as SPOF — one VPS nginx is fine until it isn't; HA pairs, anycast, or cloud load balancers in front add resilience.

Key takeaways

  • A reverse proxy sits in front of servers — terminating TLS, routing by host and path, and hiding backend topology from clients.
  • TLS termination at the edge centralizes certificates and offloads crypto; re-encrypt upstream when crossing untrusted networks.
  • Path routing lets one hostname serve static files, APIs, and WebSockets to different upstream pools — watch location block precedence.
  • Enable keepalive upstreams, health checks, and correct WebSocket upgrade headers for production reliability.
  • Enforce rate limits and security headers at the proxy; firewall backends so traffic cannot bypass the edge.
  • In Kubernetes, Ingress controllers are managed reverse proxies — same mental model, different config surface.

Related reading