Skip to content

TLS & Reverse Proxy

Breeze ships with Caddy as the default reverse proxy. It automatically provisions TLS certificates from Let’s Encrypt.

Caddy’s configuration lives in one canonical file, docker/Caddyfile.prod, which every Breeze compose file mounts read-only into the container (./docker/Caddyfile.prod:/etc/caddy/Caddyfile:ro) — there is no separate config generated inline, and no other Caddyfile to keep in sync. Edit that file directly if you need to add a route.

Routing is more than a two-way split between web and api. In match order, the file routes:

Path Destination Notes
/api/v1/mcp/sse, /api/v1/helper/chat/sessions/*/messages, /api/v1/ai/sessions/*/stream api:3001 SSE/streaming — no compression, no buffering
/api/v1/auth/billing-init billing:3002 Billing auth init
/activate/complete, /activate/* (with a status query) web:4321 Account-activation pages
/oauth/consent, /oauth/consent/* web:4321 OAuth consent UI — matched before the API’s /oauth/* block
/oauth/*, the OAuth .well-known endpoints api:3001 OAuth provider (authorize, token, registration, discovery)
/billing/invoices*, /billing/quotes* web:4321 MSP invoicing/quotes UI — matched before the billing sidecar
/billing, /billing/* billing:3002 Hosted billing UI sidecar
/portal, /portal/* portal:4322 Customer portal
/api/*, /s/*, /health*, /ready, /metrics/*, /i/* api:3001 General API traffic. Also normalizes the client-certificate assertion headers used by agent mTLS — see Certificate Pinning & mTLS
everything else web:4321 Dashboard (catch-all)

The same security headers shown below are set globally on every response.

  1. Set BREEZE_DOMAIN and ACME_EMAIL in your .env.prod
  2. Ensure port 80 and 443 are open and DNS points to your server
  3. Caddy requests a certificate from Let’s Encrypt on first start
  4. Certificates auto-renew 30 days before expiry

Internal CA for domains Let’s Encrypt cannot reach

Section titled “Internal CA for domains Let’s Encrypt cannot reach”

If your Breeze domain is an internal name that only resolves on your LAN or VPN, or a host whose ports 80 and 443 are not open to the internet, Let’s Encrypt cannot validate it. Left alone, the failed certificate order aborts the TLS handshake and the browser reports ERR_SSL_PROTOCOL_ERROR with nothing to click through — which reads like a broken deployment rather than a certificate problem.

Set CADDY_LOCAL_CERTS to the literal value local_certs and Caddy issues from its own internal certificate authority instead of trying Let’s Encrypt:

.env.prod
CADDY_LOCAL_CERTS=local_certs

Browsers will warn on the self-issued certificate until you trust the internal root on each client machine. Export it with:

Terminal window
docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt

Leave the variable empty for any internet-reachable deployment. You do not need it for localhost or a bare IP address, which Caddy already self-signs.

Caddy automatically handles WebSocket upgrade for:

  • Agent connections at /api/v1/agent-ws/:id/ws
  • Remote desktop / terminal sessions at /api/v1/remote/sessions/:id/ws, /api/v1/desktop-ws/*, and /api/v1/tunnel-ws/*
  • The one-time-ticket event stream at /api/v1/events/ws

It also disables buffering (but does not upgrade the connection) for the SSE endpoints used by MCP and streaming AI chat: /api/v1/mcp/sse, /api/v1/helper/chat/sessions/*/messages, and /api/v1/ai/sessions/*/stream.

No additional configuration needed.

If you prefer nginx, here’s an equivalent configuration.

server {
listen 80;
server_name breeze.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name breeze.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/breeze.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/breeze.yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# API routes
location /api/ {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
location /health {
proxy_pass http://127.0.0.1:3001;
}
# Customer portal (Astro SSR on :4322 under the /portal base path). This
# mirrors Caddy's `path /portal /portal/*`: an exact match for /portal
# plus a prefix match for everything under it (so it can't also grab an
# unrelated path like /portalX). The portal emits /portal-prefixed URLs,
# so keep the prefix — no trailing slash on proxy_pass. Its API calls
# stay on the /api/ block above.
location = /portal {
proxy_pass http://127.0.0.1:4322;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /portal/ {
proxy_pass http://127.0.0.1:4322;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Web dashboard
location / {
proxy_pass http://127.0.0.1:4321;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

Both configurations include these security headers:

Header Value Purpose
Strict-Transport-Security max-age=31536000; includeSubDomains; preload Force HTTPS for 1 year
X-Content-Type-Options nosniff Prevent MIME sniffing
Referrer-Policy strict-origin-when-cross-origin Control referrer information