Skip to content

TLS & Reverse Proxy

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

The Caddyfile is generated inline inside docker-compose.yml — there is no separate config file to manage. A reference copy also exists at docker/Caddyfile.prod, but the compose-generated version is what runs in production.

{
email {$ACME_EMAIL}
}
{$BREEZE_DOMAIN} {
@api path /api/* /health /ready /metrics/*
handle @api {
encode zstd gzip
reverse_proxy api:3001
}
handle {
encode zstd gzip
reverse_proxy web:4321
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
  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/agents/:id/ws
  • Terminal sessions at /api/v1/terminal/:deviceId
  • Real-time dashboard updates

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