Configuring Caddy and Traefik for SSE Permalink to this section

Part of Proxy & CDN Configuration for SSE, under SSE Protocol Fundamentals & Architecture.

Caddy and Traefik are both written in Go, and both inherit a convenient behaviour from Go’s reverse-proxy machinery: responses with Content-Type: text/event-stream are flushed to the client immediately rather than on an interval. That makes Server-Sent Events work out of the box more often than with other proxies. The remaining issues are compression middleware, timeouts that some versions and configurations impose, and load balancing for long-lived connections. This guide covers each for both proxies.

Symptom & Developer Intent Permalink to this section

  • Streams work with Caddy or Traefik in front, until a compression middleware or directive is added.
  • Streams end after a fixed interval that matches an entry point or transport timeout.
  • Events are delayed when the upstream does not set the content type until after the first write.
  • One backend holds most connections after a deploy.
  • Health checks mark a busy SSE backend as down.

The intent is a working stream route in either proxy with deliberate compression, timeout and balancing settings.

Root Cause Analysis Permalink to this section

Go’s httputil.ReverseProxy decides how often to flush based on the response: for text/event-stream, or a response with no known length, it flushes after every write. Caddy and Traefik build on that behaviour, so events are forwarded promptly if the upstream sets the correct content type in its response headers before the first byte.

How a Go-based proxy decides to flush Flow from an upstream response through a content-type check to immediate flushing for text/event-stream, compared with interval-based flushing for other types. How a Go-based proxy decides to flush Upstream response headers first headers Content-Type check text/event-stream? yes Flush every write immediate forward Client event on arrival Any compression middleware in between re-introduces buffering.
The content type is the switch. An upstream that sends the right header gets immediate forwarding without any proxy configuration.

Compression is the main way that behaviour is lost: a gzip or zstd encoder between proxy and client accumulates bytes before emitting a compressed block. Timeouts are the second: server-level read, write and idle timeouts on entry points or listeners can cap long responses depending on version and configuration.

Step-by-Step Resolution Permalink to this section

Step 1 — Caddy: proxy the stream route without encoding Permalink to this section

app.example.com {
    @stream path /api/stream*
    handle @stream {
        reverse_proxy sse1:8080 sse2:8080 sse3:8080 {
            lb_policy least_conn            # long-lived connections
            flush_interval -1               # flush every write (explicit, even if auto-detected)
            health_uri /healthz
            health_interval 10s
        }
    }

    handle {
        encode zstd gzip                    # compression only for everything else
        reverse_proxy api:8080
    }
}

Placing encode only in the non-stream handle block is the simplest way to guarantee event streams are never compressed. flush_interval -1 makes immediate flushing explicit, which also covers upstreams that forget the content type.

Step 2 — Caddy: check server timeouts Permalink to this section

Caddy’s HTTP server has read, write and idle timeout options in the global servers block. A write timeout applies to the entire response and would end streams; leave it unset (the default) or scope the setting carefully:

{
    servers {
        timeouts {
            read_body   30s     # request bodies only
            idle        5m      # between requests on keep-alive connections
            # write     — leave unset: it would cap total response time, including streams
        }
    }
}

Step 3 — Traefik: route, balance and exclude from compression Permalink to this section

# dynamic configuration (file provider)
http:
  routers:
    sse:
      rule: "Host(`app.example.com`) && PathPrefix(`/api/stream`)"
      service: sse
      entryPoints: [websecure]
      # no compress middleware on this router
    api:
      rule: "Host(`app.example.com`)"
      service: api
      middlewares: [compress]
  middlewares:
    compress:
      compress:
        excludedContentTypes: ["text/event-stream"]    # belt and braces
  services:
    sse:
      loadBalancer:
        servers:
          - url: "http://sse1:8080"
          - url: "http://sse2:8080"
        healthCheck: { path: /healthz, interval: 10s, timeout: 3s }

Traefik’s weighted round-robin balances new connections evenly but does not account for connections already open; recycle streams on a jittered maximum age so imbalance after deploys decays, as described in rebalancing SSE connections after a deploy.

Step 4 — Traefik: review entry point timeouts Permalink to this section

# static configuration
entryPoints:
  websecure:
    address: ":443"
    transport:
      respondingTimeouts:
        readTimeout: 60s      # reading the request; does not limit response streaming
        writeTimeout: 0s      # 0 = no limit; a finite value would end long streams
        idleTimeout: 180s     # idle keep-alive connections between requests

Defaults have changed between Traefik versions; state the values explicitly so upgrades do not change streaming behaviour silently.

SSE settings in Caddy and Traefik Matrix comparing Caddy and Traefik on flushing, compression exclusion, response duration timeouts and balancing for SSE routes. SSE settings in Caddy and Traefik Concern Caddy Traefik Immediate flush auto + flush_interval -1 auto for event streams Compression scope encode to other routes no compress on router Response-duration timeout leave write unset writeTimeout 0s Balancing lb_policy least_conn WRR + recycling
Both flush event streams immediately. The work is keeping compression and write timeouts away from the stream route.

When Traefik runs as a Kubernetes ingress, the same settings move into CRDs: an IngressRoute for the stream path without the compress middleware, and the entry point timeouts in the Helm values or static configuration. Traefik also offers sticky sessions through cookies; avoid them for SSE unless streams cannot be resumed from shared storage, because stickiness preserves exactly the imbalance that deploys create. Caddy deployed as an ingress (via its Kubernetes integrations) uses the same Caddyfile semantics shown above.

Both proxies terminate TLS and speak HTTP/2 to browsers by default. The upstream hop is HTTP/1.1 unless configured otherwise, which is fine for SSE; do not enable HTTP/2 to backends that send Connection headers, or the proxy may reject or reset responses as described in debugging HTTP/2 stream resets.

Step 5 — Make health checks independent of stream load Permalink to this section

Health endpoints must answer quickly even when a backend holds tens of thousands of streams. Serve them from the same process but with no dependency on the stream machinery, and do not route them through any per-connection limits. A health check that times out under load takes a healthy backend out of rotation, pushing its clients onto the others.

Validation & Monitoring Permalink to this section

# No Content-Encoding on the stream, even when the client offers gzip.
curl -sN -H 'Accept-Encoding: gzip, zstd' -D - https://app.example.com/api/stream -o /dev/null --max-time 5 \
  | grep -i -E 'content-type|content-encoding'

# Idle survival past any entry point timeout.
timeout 400 curl -sN https://app.example.com/api/stream | grep -c '^:'
First-event delay through each proxy configuration Bar chart comparing the delay before the first small event reaches the client through Caddy or Traefik with compression enabled on the stream route and without it. First-event delay through each proxy configuration Caddy, encode on stream until buffer fills Caddy, stream excluded 2 ms Traefik, compress on router until buffer fills Traefik, no compress 2 ms milliseconds from write to client, first 60-byte event
Immediate flushing is the default in both. Compression on the stream route is what undoes it.

Both proxies export Prometheus metrics; watch open connections per backend and request durations for the stream router, which should reflect session lengths rather than a fixed cap.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Do Caddy and Traefik buffer SSE by default?

No. Both flush text/event-stream responses immediately. Buffering appears when compression is applied to the stream or when the upstream does not declare the content type.

Why does compression delay events?

Compressors emit output in blocks. Small events sit in the compressor's buffer until enough data accumulates, which on a quiet stream can be many seconds.

Can Traefik do least-connections balancing?

Its standard load balancer is weighted round-robin. Combine it with connection recycling on the backend so long-lived connections redistribute over time.

Should HTTP/2 be enabled to clients?

Yes. Both proxies negotiate HTTP/2 with browsers by default over TLS, which removes the six-connection-per-origin limit for streams.