TLS & Reverse Proxy
Caddy (Default)
Section titled “Caddy (Default)”Breeze ships with Caddy as the default reverse proxy. It automatically provisions TLS certificates from Let’s Encrypt.
Production Caddyfile
Section titled “Production Caddyfile”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.
How It Works
Section titled “How It Works”- Set
BREEZE_DOMAINandACME_EMAILin your.env.prod - Ensure port 80 and 443 are open and DNS points to your server
- Caddy requests a certificate from Let’s Encrypt on first start
- 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:
CADDY_LOCAL_CERTS=local_certsBrowsers will warn on the self-issued certificate until you trust the internal root on each client machine. Export it with:
docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crtLeave 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.
WebSocket Proxying
Section titled “WebSocket Proxying”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.
Nginx (Alternative)
Section titled “Nginx (Alternative)”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; }}Security Headers
Section titled “Security Headers”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 |