notes

working notes on backend, infra, and the occasional yak shave.

← back

Reverse-proxying WebSockets with Caddy v2

2026-04-12 · ~3 min read

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-For and X-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 with wss://, Caddy unwraps, and the upstream sees ws://. That confused me for longer than I'd like to admit.