Reverse Proxies Explained: Nginx, Caddy, and Traefik in Front of Your App
A reverse proxy sits between the internet and your app, handling TLS, routing, compression, and more. What reverse proxies do, how Nginx, Caddy, and Traefik differ, forwarded headers and trusting the real client IP, WebSockets and streaming, timeouts and body limits, and common 502/504 causes.
In most production setups, your app doesn't talk to the internet directly. A reverse proxy receives every request, then forwards it to your app. Platforms hide it from you; self-hosting makes you configure it. Either way, many "works locally, breaks in production" bugs live in this layer.
What a reverse proxy does
Internet → Reverse proxy (443) → your app (localhost:3000)
→ another app (localhost:4000)
- Terminates TLS — handles HTTPS certificates, so your app speaks plain HTTP internally. (How Automatic SSL Works.)
- Routes — by hostname (
api.example.com→ API,app.example.com→ web) or path. - Load balances across several instances of your app.
- Compresses responses (gzip, Brotli, zstd).
- Serves static files efficiently.
- Enforces limits — request body size, timeouts, rate limits.
- Hides internals — only the proxy's ports are public. (What Is an IP Address and a Port?.)
(A forward proxy is the opposite: it sits in front of clients, controlling their outbound traffic. Agent Egress Control covers that side.)
Nginx, Caddy, Traefik
| Nginx | Caddy | Traefik | |
|---|---|---|---|
| Config | Text config files | Caddyfile or JSON | Labels/tags on containers, files, or Kubernetes resources |
| Automatic HTTPS | Via add-on tooling (e.g. certbot) | Built in, on by default | Built in (ACME) |
| Dynamic discovery | Reload on change | API / reload | Watches Docker/Kubernetes and reconfigures itself |
| Sweet spot | Mature, fast, ubiquitous; very flexible | Simplest correct config for a few sites | Container platforms with many services |
A Caddy site with automatic HTTPS:
app.example.com {
reverse_proxy localhost:3000
}
The equivalent Traefik setup is labels on your container:
labels:
- traefik.enable=true
- traefik.http.routers.app.rule=Host(`app.example.com`)
- traefik.http.routers.app.tls.certresolver=letsencrypt
- traefik.http.services.app.loadbalancer.server.port=3000
Forwarded headers: who is the client?
Behind a proxy, your app sees every request as coming from the proxy. The original details travel in headers:
| Header | Carries |
|---|---|
X-Forwarded-For |
Client IP (a chain, appended by each proxy) |
X-Forwarded-Proto |
Original scheme (https) |
X-Forwarded-Host |
Original host |
Forwarded |
Standardised combined form (RFC 7239) |
Your app must be told to trust these only from your proxy. In Express, app.set("trust proxy", 1) (one hop); frameworks have equivalents. Get it wrong in either direction and:
- Not trusting → every client has the proxy's IP (rate limits hit everyone at once; logs are useless), and the app thinks requests are
http, generating wrong redirect URLs or refusing to setSecurecookies. (What Is a Cookie?.) - Trusting blindly → clients forge
X-Forwarded-Forand get unlimited identities for rate limiting and audit logs. (Implementing Rate Limiting.)
WebSockets and streaming
- WebSockets need the
Upgrade/Connectionheaders passed through. Caddy and Traefik do this automatically; Nginx needsproxy_http_version 1.1and explicit upgrade headers. (What Are WebSockets?.) - Server-sent events and streamed responses (including AI token streaming) break when the proxy buffers responses: users see nothing, then everything at once. Disable buffering for those routes — in Nginx,
proxy_buffering off, or the app can sendX-Accel-Buffering: no. Also check compression isn't buffering the stream. - Long-lived connections need idle timeouts longer than your heartbeat interval.
Timeouts, body size, and the 502/504 family
Common production errors and where they usually come from:
| Error | Usual cause at the proxy layer |
|---|---|
| 502 Bad Gateway | App not running, crashed, listening on the wrong port, or bound to 127.0.0.1 inside a container |
| 504 Gateway Timeout | App took longer than the proxy's read timeout (slow query, slow upstream API) |
| 413 Content Too Large | Upload exceeded the proxy's body limit (Nginx defaults to 1 MB) |
| Redirect loop | App forces HTTPS but doesn't trust X-Forwarded-Proto, so it keeps redirecting |
(HTTP Status Codes Explained.) For large uploads, consider bypassing the proxy entirely with direct-to-storage uploads. (Direct-to-Storage Uploads With Presigned URLs.)
Zero-downtime deploys
The proxy is what makes rolling deploys possible: route to new instances once their health checks pass, drain old ones. Traefik and Caddy can do this with health checks and dynamic upstreams; container platforms do it for you. (Zero-Downtime Deploys.)
Security headers
The proxy is a convenient place for headers every response should carry — HSTS, X-Content-Type-Options: nosniff, Referrer-Policy. Per-request headers like a nonce-based CSP belong in the app. (Content Security Policy.)
Checklist
- TLS terminated at the proxy with automatic renewal; HTTP redirected to HTTPS
- App trusts forwarded headers from the proxy only (correct hop count)
- WebSocket upgrades pass through; streaming routes unbuffered
- Timeouts match the slowest legitimate request; body limits match uploads
- App listens on the address the proxy can reach; only the proxy is public
- Health checks gate traffic to new instances
EasySpawn runs a managed Traefik layer in front of every workspace — automatic Let's Encrypt certificates, routing for your custom domains, and WebSocket support — so you configure your app, not the proxy. See how it works or join the waitlist.
Related: Self-Hosting Next.js Without Vercel · VPS vs PaaS · What Is a CDN?
Keep reading
HTTP Caching Headers: Cache-Control, ETags, and Getting It Right
Most caching bugs are header bugs. How Cache-Control directives actually behave (max-age, s-maxage, no-cache vs no-store, private, immutable, stale-while-revalidate), how ETags and 304s work, Vary, and a practical header policy for static assets, HTML, APIs, and personalised pages.
Secrets Management Beyond .env Files
.env files are fine on a laptop and fragile everywhere else. Where secrets should live in production and CI, secret managers vs platform env vars, OIDC to remove long-lived CI credentials, rotation, least privilege, keeping secrets out of logs and AI agent context, and a practical maturity path.