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.
Trusting the internal CA on agents
Section titled “Trusting the internal CA on agents”Browsers are not the only clients that must trust a self-issued certificate. The Breeze agent, the Windows install one-liner and the MSI all verify the server certificate against the operating system’s trust store, exactly like a browser. Until the endpoint trusts your internal root, every install fails before the agent enrolls:
| Install path | What you see |
|---|---|
| Windows MSI | “There is a problem with this Windows Installer package. A program run as part of the setup did not finish as expected.” The agent’s own message, which names the certificate problem, is in C:\ProgramData\Breeze\logs\enroll-last-error.txt and in the Windows Application event log (source BreezeAgent) |
| Windows PowerShell one-liner | Breeze: this machine rejected the TLS certificate of … (… UntrustedRoot). The text in parentheses is the reason Windows gave: UntrustedRoot means the CA is not trusted, while RemoteCertificateNameMismatch means the server URL does not match a name on the certificate. Older copies of the one-liner show Could not establish trust relationship for the SSL/TLS secure channel instead |
| macOS / Linux one-liner | curl: (60) SSL certificate problem: self-signed certificate or unable to get local issuer certificate |
breeze-agent enroll |
Enrollment failed: the TLS certificate presented by … is not trusted by this machine |
The fix is to trust the CA on each endpoint, not to turn verification off. Breeze has no option to skip certificate verification, and none is planned. An agent that accepts any certificate would hand its enrollment secret and device token to anyone who can intercept the connection.
-
Export the root certificate from the server. Trust the root, not the site certificate: Caddy renews the site certificate and its intermediate often, but the root stays the same.
Terminal window docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt > breeze-root.crtThe same file covers a bare IP address or
localhost, which Caddy also signs from its internal CA. If you front Breeze with your own proxy and a certificate from your organization’s CA, use that CA’s root instead. -
Install it into the machine trust store on every endpoint, before you run the installer. The agent runs as
SYSTEMorroot, so a per-user store does not work.Terminal window # Elevated PowerShellImport-Certificate -FilePath .\breeze-root.crt -CertStoreLocation Cert:\LocalMachine\RootTo reach a whole fleet, deploy the certificate with Group Policy (Computer Configuration → Policies → Windows Settings → Security Settings → Public Key Policies → Trusted Root Certification Authorities) or an Intune trusted-certificate profile.
Terminal window sudo security add-trusted-cert -d -r trustRoot \-k /Library/Keychains/System.keychain breeze-root.crtFor managed Macs, push the certificate in an MDM configuration profile.
Terminal window # Debian / Ubuntusudo cp breeze-root.crt /usr/local/share/ca-certificates/breeze-root.crtsudo update-ca-certificates# RHEL / Fedora / Rocky / Almasudo cp breeze-root.crt /etc/pki/ca-trust/source/anchors/breeze-root.crtsudo update-ca-trust -
Run the installer again.
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.
Troubleshooting: remote tools time out / event socket returns 400 behind your own proxy
Section titled “Troubleshooting: remote tools time out / event socket returns 400 behind your own proxy”With FORCE_HTTPS=true, a request that reaches the API looking like plain http with a Host other than the one in PUBLIC_API_URL is rejected with 400 and the body {"error":"FORCE_HTTPS_NON_CANONICAL_HOST"}. The API request log shows the same thing as reason=force_https_non_canonical_host on the --> line. Browsers hide the status and body of a failed WebSocket upgrade, so from the page it only reads as “failed to connect”.
Check that your proxy:
- forwards
X-Forwarded-Proto: httpson WebSocket upgrade requests, not just plain requests; - preserves the original
Hostheader on upgrades (nginx:proxy_set_header Host $host;); - has its IP in
TRUSTED_PROXY_CIDRS, otherwise the API ignores the forwarded headers.
The API also logs a rate-limited [force-https] warning on this path with the values it saw.
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 |