28 Oct 2026 · Hardening & Ops
Caddy for Next.js 15, Automatic SSL and Zero-Buffering SSE
Why NGINX plus Certbot is the long way, how Caddy terminates TLS in Docker, and why flush_interval -1 is required for AI token streams.
If your AI chat UI paints nothing until the model is done, then dumps the full answer in one frame, the reverse proxy is buffering. Next.js 15 and the AI SDK are already streaming. The proxy is not.
AgenticPacks OSS is the Next.js app and Compose: Agentic-Packs/agenticpacks-oss. There is no Caddy in that repo. The Caddyfile in this post is what Pro ships, and what you add yourself if you put AgenticPacks OSS behind TLS on a VPS.
The problem
NGINX and Caddy default to buffering upstream responses. That is correct for static files and gzip. It is wrong for Server-Sent Events and for Vercel AI SDK streams (toDataStreamResponse(), text/event-stream from a Route Handler).
The browser is waiting on chunks. The proxy is waiting to fill a buffer. You get a hang, then a burst.
For LLM tokens you need the proxy to forward each chunk as soon as Next.js writes it.
Why Caddy instead of NGINX plus Certbot
On a single VPS you want TLS, HTTP/2, HTTP/3, and a reverse proxy without a cron job.
| Topic | NGINX + Certbot | Caddy 2 |
|---|---|---|
| First TLS cert | nginx config + certbot plugin or webroot + reload | hostname block, ACME on boot |
| Renewal | systemd timer, reload on success | built in, stores certs in /data |
| Config size | server blocks, ssl_certificate paths, dhparam | ~15 lines |
| SSE / streaming | proxy_buffering off; proxy_cache off; gzip off; plus extra locations | flush_interval -1 on the proxy |
| Docker DNS | extra resolver config | service name as upstream |
| HTTP/3 | extra compile / module | :443/udp on the container |
Caddy issues Let's Encrypt or ZeroSSL certs from the email in the global block. Volume caddy_data keeps them across recreates. No Certbot container.
Production Caddyfile
Production Caddyfile for a single Next.js app:
{
email {$ACME_EMAIL}
}
www.{$DOMAIN} {
redir https://{$DOMAIN}{uri}
}
{$DOMAIN} {
reverse_proxy web:3000 {
flush_interval -1
}
encode gzip zstd
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
}
}
{$DOMAIN} and {$ACME_EMAIL} come from the Caddy container environment. Compose:
caddy:
image: caddy:2-alpine
restart: always
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
DOMAIN: ${DOMAIN}
ACME_EMAIL: ${ACME_EMAIL}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
Port 80 must be reachable for HTTP-01. Behind Cloudflare, grey-cloud the hostname until the first cert exists, then orange-cloud and set SSL to Full (strict). Universal SSL on a nested hostname that is not covered by *.domain will 525 if you orange-cloud too early.
Tired of NGINX buffering and Certbot cron jobs?
The Next.js app and Compose file are open-source: Agentic-Packs/agenticpacks-oss.
Caddy with
flush_interval -1, Terraform, and Hetzner TLS are in pre-configured Caddy and Hetzner IaC in Pro.
flush_interval -1
Caddy's reverse_proxy collects upstream bytes so encode gzip zstd can work on larger payloads. flush_interval is how long it waits before flushing to the client.
-1 means flush immediately. Each SSE event or AI SDK chunk leaves Next.js on port 3000 and hits the browser without waiting for the generation to finish.
Leave encode gzip zstd on. Caddy still compresses responses that are not streaming. Streaming responses bypass useful gzip anyway because the payload is small, already chunked, and often incompressible token text.
If you add a second location later (static, webhooks), you do not need a special case. The same proxy block is enough.
Do not put Next.js on published :3000. Caddy is the only public process.
Internal Docker network
Internet
:80 HTTP-01 + redirect
:443 TLS (Caddy)
|
| Docker bridge DNS
| web:3000 (unpublished)
| db:5432 (unpublished)
Compose should expose 3000, not ports. Postgres the same. Only Caddy maps host ports.
services:
web:
expose:
- "3000"
# no ports: section
caddy:
ports:
- "80:80"
- "443:443"
- "443:443/udp"
Caddy resolves web because both services share the default Compose network. There is no extra_hosts and no host-gateway hack. The complete multi-container Compose YAML (web, Postgres, Caddy, healthchecks) is in the production standalone deployment guide.
Where to Go From Here
You do not have to copy a Caddyfile onto a bare VPS and hope ACME works. Pick a path based on where the project is.
Option A: Free open-source baseline (MIT)
- GitHub: github.com/Agentic-Packs/agenticpacks-oss
- What is included: Next.js 15 standalone, Compose, PostgreSQL 16, Auth.js v5, single-plan ($19/mo) Stripe checkout with webhooks and a credit counter, streaming chat. No Caddy.
- Cost: free, MIT. Star the repo if this guide saved you a debugging session.
Option B: Ship with AgenticPacks Pro ($149 one-time)
If you want this proxy on Hetzner without hand-editing DNS and TLS:
- Automated Hetzner provisioning:
terraform applyfor the VPS, firewall, Caddy auto-SSL, and Compose. - Production multi-agent core: Streaming chat and LangGraph already assume zero-buffer SSE.
- Usage & Unit Economics: Stripe subscriptions, webhooks, and real-time token-to-dollar cost ledger.
