All posts

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.

TopicNGINX + CertbotCaddy 2
First TLS certnginx config + certbot plugin or webroot + reloadhostname block, ACME on boot
Renewalsystemd timer, reload on successbuilt in, stores certs in /data
Config sizeserver blocks, ssl_certificate paths, dhparam~15 lines
SSE / streamingproxy_buffering off; proxy_cache off; gzip off; plus extra locationsflush_interval -1 on the proxy
Docker DNSextra resolver configservice name as upstream
HTTP/3extra 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 apply for 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.