Reverse-proxying WebSockets with Caddy v2
Caddy is one of those tools that makes the easy thing actually easy. WebSocket reverse-proxying is in the "just works" bucket for the most part: a reverse_proxy directive will happily forward the Upgrade handshake without any extra configuration. But there are three places I've watched it quietly fail.
1. idle timeouts
By default, Caddy's reverse_proxy uses a relatively generous transport timeout, but a load balancer in front of it (or a corporate proxy on the client side) may not. We had a chat feature that worked perfectly in dev and dropped at the 60-second mark in staging. The culprit was an L7 LB upstream of Caddy with a 60s idle timeout. Pinging keep-alives from the application layer fixed it; bumping the LB's idle timeout was the proper fix.
2. mixed-route apps
If you have a single hostname serving both regular HTTP and a WebSocket endpoint, the obvious-looking config is to put reverse_proxy at the top level. That works, but it also means static assets pass through the same pipeline. Splitting the matcher tends to be clearer:
app.example.com {
@ws path /ws/*
reverse_proxy @ws 127.0.0.1:8080
handle {
root * /srv/static
file_server
}
}
This made it much easier to reason about what was hitting the app vs. served from disk.
3. health checks
Caddy's health_uri doesn't speak WebSocket. If your only listening endpoint is the WS one, you'll get false positives flapping the upstream up and down. Either expose a tiny HTTP health endpoint alongside your WS server, or disable active health checks for that upstream and rely on passive ones.
misc.
Two small things worth knowing:
- Caddy automatically sets
X-Forwarded-ForandX-Forwarded-Proto; you almost never need to set them by hand. - If you're terminating TLS at Caddy and your backend is plain HTTP, the WS handshake still works without
wss://on the inside — the client connects withwss://, Caddy unwraps, and the upstream seesws://. That confused me for longer than I'd like to admit.