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 on127.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:
- Accept the TCP connection on port 443 (or 80, then redirect to HTTPS).
- Complete the TLS handshake using the server's certificate — decrypting the request body and headers.
- Match a routing rule — usually by
Hostheader and URL path — to select an upstream pool. - Optionally transform the request — add
X-Forwarded-For, strip internal headers, enforce rate limits, or reject oversized bodies. - 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.
- 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_zoneor 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 limits —
client_max_body_sizeblocks oversized uploads that would OOM a parser. - IP allowlists — restrict
/adminto office CIDRs. - Security headers — inject
CSP,
X-Frame-Options, andReferrer-Policyonce 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.0vs127.0.0.1). - Wrong scheme in redirects — app generates
http://links because it does not know the client used HTTPS. SetX-Forwarded-Proto httpsand configure the framework's trusted proxy list. - Buffering breaks streaming — SSE and large downloads need
proxy_buffering offor 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 reloaded —
nginx -tthennginx -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
- Load balancing explained — L4 vs L7, algorithms, and health checks
- TLS and HTTPS explained — certificates, handshakes, and HSTS
- Kubernetes fundamentals — Ingress and Services as cluster proxies
- HTTP caching explained — Cache-Control, ETags, and proxy cache layers