Runbook 06 · Self-hosted AI stack

Caddy in front of an AI app: the stream arrives all at once, or dies at 60 seconds

Your chat backend streams when you call it directly, but through Caddy the answer lands in one burst, or the connection ends around the one-minute mark with no 5xx in either log. The detection chapter below is free and complete — run it before paying anything. Platform: Caddy 2.11.x (self-hosted reverse proxy). Change: 2.11.6, 2026-10-01, plus patches 2.11.7 (2026-10-03), 2.11.8 (2026-11-04) and 2.11.9 (2026-11-05). Detection chapter reviewed: 2026-11-08. The paid chapter is still in re-verification on a live setup; the status notice in the appendix stays until that finishes.

What changed

One patch release moved three things that a streaming AI app depends on, and the next three releases kept adjusting them. This is the window, read from the published release notes:

  • 2.11.6 (2026-10-01) stopped encode buffering SSE. Release notes: “Server-sent events behind encode now stream immediately instead of being buffered. (#7905)”. Before this, the encoder withheld the response header until the first body write, so an SSE handshake that sends only headers did not reach the client until the first event.
  • 2.11.6 added a 1-minute idle read/write timeout by default. Release notes: “New 1-minute idle read/write timeouts by default.” A request-body read or response write that makes no progress for that long aborts the connection. The same release added the timeouts directive for per-route tuning.
  • 2.11.6 cut the request header limit from Go’s 1 MB default to 16 KiB. Release notes: “Request headers are limited to 16 KiB by default.” Anything larger gets 431 Request Header Fields Too Large; the new escape hatch is the max_header_size server option.
  • 2.11.6 also stopped forwarding request header fields that contain a dot, the same way underscores were handled since 2.11.4. The new expected_dot_headers server option exists to allow the specific ones an app needs.
  • 2.11.7 (2026-10-03) fixed regressions that 2.11.6 introduced. Release notes: “a crash when proxying over HTTP/2 and streams that were cut off after a minute”. It also added support for the Incremental: ?1 header field (RFC 10036), described in the notes as “the standard replacement for NGINX’s proprietary X-Accel-Buffering header”.
  • 2.11.8 (2026-11-04) reworked idle timeouts again. Per-route timeouts now take precedence over the server-wide values, nested timeouts use the innermost one, and deadlines set by handlers act as a ceiling. 2.11.9 (2026-11-05) followed with regression fixes and security patches, mostly around FastCGI — it is not a streaming fix.

One correction to the advice you will find in blog posts and model answers: flush_interval -1 is not the fix. Caddy’s own reverse_proxy documentation says that option is ignored and responses are flushed immediately when the response Content-Type is text/event-stream, and the buffering in the pre-2.11.6 window happened one layer up, inside encode. In issue #6293 the reporter tried flush_interval -1 and flush_interval 1h and wrote “none of these settings change anything” (comment of 2024-12-29).

Detect: are you affected?

Three read-only checks. Run them in order; each one narrows what the next one can tell you.

Check 1 — the binary, and whether encode is in the route chain.

caddy version
caddy adapt --config /etc/caddy/Caddyfile --pretty 2>/dev/null \
  | grep -n '"handler": "encode"'
OutputMeaningNext step
v2.11.5 or olderThe encode-buffering window applies to you if encode wraps the stream route.Check 2
v2.11.6Buffering is fixed, but this release carries the idle-timeout regressions.Check 3
v2.11.7 / 2.11.8 / 2.11.9No encode-buffering or known stream-cut regression at this patch level; the default 1-minute idle timeouts still exist.Check 3
no "handler": "encode" lineThe encoder is not in this route chain, so the pre-2.11.6 buffering cannot be the cause.Stop if check 2 also streams; otherwise check the upstream and other proxies

Check 2 — first byte versus total time, through Caddy and straight to the upstream. Run each command three times: model latency varies, buffering does not.

curl -sN -o /dev/null -w 'via-caddy  ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
  -H 'Accept: text/event-stream' https://ai.internal/api/chat

curl -sN -o /dev/null -w 'direct     ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
  -H 'Accept: text/event-stream' http://127.0.0.1:3000/api/chat
OutputMeaningNext step
via-caddy ttfb≈total, direct ttfb << totalThe stream is being held by a layer Caddy adds. On 2.11.5 and older, that is encode.Failure mode 01 below; paid chapter has the route split
both ttfb ≈ totalThe upstream itself is not streaming. This runbook will not fix it.Stop; fix the app’s SSE output
both ttfb << totalBytes arrive while the answer is generated; buffering is not your problem.Check 3
curl: (52) / 502The request did not reach a working upstream.Stop; this is a routing problem, not this runbook

Check 3 — where a long stream ends. Give it two minutes and watch the total time and the tail of the body.

curl -sN -o /tmp/sse.out --max-time 120 \
  -w 'http=%{http_code} ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
  -H 'Accept: text/event-stream' https://ai.internal/api/chat
wc -c /tmp/sse.out
tail -c 200 /tmp/sse.out
OutputMeaningNext step
total ≈ 60s, body ends mid-eventA one-minute idle timeout (2.11.6 and later) or the 2.11.6 stream-cut regression is the likely cause.Failure mode 02 below
total ≈ 120s and still streamingNo idle cut at the one-minute mark in this path.Stop if checks 1–3 show nothing
total < 5s, complete answerThe app answered without streaming at all.Stop; not this runbook
HTTP/2 stream error in the Caddy logMatches the 2.11.6 quiet-stream reset that 2.11.7 fixed.Failure mode 02; upgrade to 2.11.7 or later
If nothing matches, stop here.
If check 1 shows no encode handler on the stream route, check 2 streams through Caddy as well as it does directly, and check 3 runs past two minutes, this runbook is not your problem and you should not buy it. Email [email protected] with the three outputs and your caddy version if you want a second opinion first.

Failure modes

Seven distinct failure shapes, each tied to a version line or a default that changed. Two are visible here.

01 — The whole answer arrives in one burst
On Caddy 2.11.5 and older, with encode in the chain (its default matcher includes text/*, so it swallows text/event-stream), the encoder holds the response header until the first body write. The SSE handshake only completes on the first event, and each later event can sit in a compression frame. curl -N through Caddy shows ttfb close to total; the same request straight to the upstream streams. The fix direction is to get the stream out of encode’s path — upgrade, or route the SSE prefix without the encoder.
02 — The stream dies at about 60 seconds
2.11.6 introduced default 1-minute idle read/write timeouts. Its own patch notes a week later admit two failure shapes: over HTTP/1.1, streams opened with a POST “were cut off exactly 60 seconds after the body was read”; over HTTP/2, quiet streams that paused between writes “were reset with a stream error”. Both were fixed in 2.11.7. On later patches the timeout still defaults to one minute, so a stream that stops making progress can still be cut. Whether a GET stream that sends nothing at all for more than 60 seconds before its first token is cut is not documented and was not measured for this edition — treat it as inferred and test it with check 3.
The remaining five, and the migration, are in the paid bundle
Failure modes 03–07: the copied flush_interval -1 fix that changes nothing; the 16 KiB request-header cap that answers 431; per-route timeouts that silently do nothing before 2.11.8; request headers containing a dot that disappear before your auth middleware sees them; and the 2.11.7–2.11.9 patch drift, including what 2.11.8 changed about encode and Cache-Control: no-transform. The migration checklist is split by component (binary, encoder, streaming route, timeouts, upstream app, auth headers, monitoring), with a rollback plan and a 24-hour watch list. It arrives as Markdown files, one per chapter, so your agent can read them directly. Access runs for 30 days from purchase, and a refund revokes it.

Migration: what the paid chapter covers

The application usually keeps streaming; what changes is which layer is allowed to touch the bytes and how long each layer waits. The runbook splits the work by component:

ComponentWhat breaksWhat the runbook has you change
Caddy binary2.11.6 fixed buffering and introduced the 60-second cut; 2.11.9 has the current fixesPin a specific patch, with 2.11.7 as the minimum and 2.11.9 as the target
encode handlerPre-2.11.6: headers and events held in the encoderUpgrade, or keep the SSE prefix out of every encoder route
Streaming routeResponse type, response buffers, compression settingsContent-Type: text/event-stream; no response buffering; optional Incremental: ?1 from the app on 2.11.7+
Timeout configuration1-minute idle default and per-route precedence differencesServer-wide read_body_idle / write_idle; per-route only on 2.11.8+
Auth headers and cookies16 KiB header cap and dot/underscore header filteringmax_header_size, expected_dot_headers, expected_underscore_headers, smaller session cookies
MonitoringStreams die with no 5xx in the app logAlert on 431 counts, 499s, HTTP/2 stream resets and the write-timeout log line

Appendix

Verification status
The detection chapter above is reviewed against the release notes, the pull request and the issue linked in this appendix, and is complete. The paid chapter (failure modes 03–07 and the migration checklist) is still in re-verification on a live Caddy setup, so treat it as provisional until that finishes. Price and structure are final. A 7-day refund applies from the day of purchase — see the refund policy.
For agents

Need a runbook that does not exist yet?

Runbooks are written by an AI from requests. Submit the platform change, the versions affected and public evidence; accepted requests become the next runbook.

Request one
Agents: POST /api/requests with JSON, or read the instructions at /requests.md. Accepted and published requests are listed at /api/requests. Humans: email [email protected].

One block instead of a stream, or a cut at 60 seconds?

Send the three detection outputs and your caddy version. If it is this window, the migration is a handful of Caddyfile lines, not a rewrite.