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.
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.
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 '^:'
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.