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.

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.

  1. 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.crt

    The 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.

  2. Install it into the machine trust store on every endpoint, before you run the installer. The agent runs as SYSTEM or root, so a per-user store does not work.

    Terminal window
    # Elevated PowerShell
    Import-Certificate -FilePath .\breeze-root.crt -CertStoreLocation Cert:\LocalMachine\Root

    To 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.

  3. Run the installer again.

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: https on WebSocket upgrade requests, not just plain requests;
  • preserves the original Host header 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.

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