-
Pick the proxy by requirement — default to nginx.
| Proxy | Pick when | Watch out |
|---|
| nginx | General L7 in front of HTTP/HTTPS apps — the default | Active health checks need nginx Plus; OSS only does passive max_fails |
| Envoy | Dynamic config via xDS, gRPC/HTTP2, fine-grained circuit breaking, outlier detection | Steep config; run with a control plane (Istio/Contour/Gloo) for anything large |
| Caddy | You want automatic TLS (ACME) with near-zero config | Less knob-level control over upstreams/retries |
| HAProxy | Heavy L4 (TCP) LB, max throughput, advanced balancing/observability | L7 ergonomics weaker than nginx for content routing |
For a typical web service: nginx terminating TLS, round-robin or least-conn upstream, passive health checks. Reach for Envoy only when you genuinely need dynamic upstreams or per-endpoint outlier ejection.
-
Define the upstream pool + algorithm — least-conn is the safer default for mixed latency.
upstream app {
least_conn; # round-robin is fine for uniform requests; least_conn for variable latency
server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.12:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.13:8080 max_fails=3 fail_timeout=10s backup; # only when primaries are down
keepalive 64; # REUSE upstream conns — without this every request does a fresh TCP+TLS handshake
}
- round-robin (default): uniform, cheap requests.
- least-conn: requests with variable duration — avoids piling onto a slow node.
- consistent-hash (
hash $arg_key consistent; / Envoy ring-hash): only when a key must stick to a backend (cache affinity, sharding). Plain ip_hash rebalances badly when a node leaves; use consistent so a single ejection doesn't reshuffle every key.
-
Set timeouts at EVERY hop — a proxy timeout shorter than the app is the #1 cause of 502/504. A 502 = backend refused/reset the connection; a 504 = backend accepted but didn't answer before proxy_read_timeout. The proxy's read timeout must be longer than the slowest legitimate backend response, and the backend's own keepalive must be longer than the proxy's so the proxy never reuses a socket the backend just closed (classic race → sporadic 502).
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection ""; # required so keepalive to upstream actually works
proxy_connect_timeout 2s; # TCP connect to backend — short; a backend that won't accept is dead
proxy_send_timeout 30s; # writing the request body to backend
proxy_read_timeout 60s; # waiting for the backend's response — MUST exceed slowest real response
}
# And: backend keepalive_timeout (e.g. 75s) > nginx upstream idle reuse window, to avoid the reuse-after-close 502.
Envoy: set connect_timeout on the cluster and route.timeout per route; default route timeout is 15s and silently truncates long requests — set it deliberately.
-
Add health checks — passive at minimum, active if your proxy supports it. Passive ejection (max_fails/fail_timeout, Envoy outlier detection) reacts only to real request failures, so a freshly-booted-but-broken node still gets traffic until it fails N live requests. Active checks (nginx Plus health_check, HAProxy option httpchk, Envoy health_checks) probe a /healthz endpoint and eject before user traffic hits it.
- Health endpoint must check dependencies (DB, cache reachable), not just "process is up" — otherwise you keep a node that 500s on every real request.
- Set an explicit
unhealthy→healthy hysteresis (e.g. eject after 3 fails, re-add after 2 passes) so a flapping node doesn't oscillate in and out of rotation.
-
TLS: terminate at the proxy unless the backend legally must see the cert. Terminate (decrypt at proxy, plaintext or re-encrypt to backend) for HTTP routing, header inspection, and central cert management — the common case. Passthrough (L4 stream/SNI routing, proxy never decrypts) only for end-to-end encryption mandates or non-HTTP TLS. When terminating, forward the original scheme/IP so the app builds correct URLs and logs the real client:
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; # app uses this to know the request was HTTPS
Pin ssl_protocols TLSv1.2 TLSv1.3; and a modern cipher suite; redirect :80 → :443.
-
Retry idempotent requests ONLY, with circuit breaking. Auto-retrying a POST/PATCH that timed out can double-charge a card or double-write. Restrict retries to safe methods + connect/early failures, cap attempts, and stop retrying once the backend is clearly down.
proxy_next_upstream error timeout http_502 http_503; # NOT non_idempotent — never blindly retry POST
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 10s;
Envoy: retry_policy with retry_on: connect-failure,refused-stream,unavailable, num_retries: 2, plus retry_back_off. Add circuit breaking (Envoy circuit_breakers max connections/pending/retries, or outlier detection ejecting a 5xx-storming host) so retries don't amplify load against a struggling backend into a full meltdown.
-
Sticky sessions only when state truly demands it. Cookie/affinity routing (sticky cookie, Envoy hash policy) pins a client to one backend — necessary for in-memory session state, fatal for even load balancing and graceful drain (a drained node's clients all break). First fix the state: move sessions to Redis/JWT so any backend serves any user, then drop stickiness. Only keep it for unavoidable backend-local state, and pair it with consistent hashing so losing one node reshuffles minimally.
-
Make reloads zero-drop (graceful drain). A naive restart cuts in-flight connections → user-visible 5xx during every deploy.
- nginx:
nginx -t && nginx -s reload — the master spins up new workers on the new config and lets old workers finish in-flight requests before exiting. Never kill -9 / hard restart for a config change.
- HAProxy: run with
-sf $(cat pid) (seamless finish) or the master-worker socket reload.
- Envoy: hot restart / xDS push drains the old listener.
- For removing a backend: first mark it
down/drain in the pool and reload so the proxy stops sending new requests, wait for in-flight to finish, then stop the backend. Tie the backend's shutdown to its readiness probe (fail /healthz → proxy ejects → then SIGTERM) so the LB drains it before it dies.