Cloudflare Tunnel
A Cloudflare Tunnel lets you expose Breeze without opening any inbound ports on your server. The cloudflared daemon makes an outbound connection to Cloudflare’s edge, and all traffic reaches Breeze through that tunnel. Your origin IP stays hidden, and the firewall can deny all inbound traffic.
This is a popular self-hosting setup, and the bundled Caddy config is built to support it out of the box.
Traffic Chain
Section titled “Traffic Chain”Internet → Cloudflare edge → cloudflared → Caddy → API / WebBoth the dashboard and agent traffic arrive on the same hostname (port 443) and are split by path inside Caddy:
| Path | Backend |
|---|---|
| /api/v1/agents/*, /api/v1/agent-ws/* | API (agent REST + WebSocket) |
| /api/v1/desktop-ws, /api/v1/tunnel-ws, /api/v1/remote/sessions | API (remote desktop / terminal) |
| /api/*, /health, /metrics/* | API |
| / (catch-all) | Web dashboard |
TLS Termination: HTTP Origin Required
Section titled “TLS Termination: HTTP Origin Required”Cloudflare terminates HTTPS at its edge, and the tunnel from cloudflared to your server is already encrypted. The hop from cloudflared into Breeze must therefore be plain HTTP. If Caddy also tries to obtain its own Let’s Encrypt certificate, the two TLS layers collide — ACME validation has no inbound port to answer on, and the tunnel fails to connect.
There are two supported ways to satisfy this. Option A keeps Caddy as the path router (recommended). Option B bypasses Caddy and routes paths in the tunnel itself.
Option A — Caddy in HTTP mode (recommended)
Section titled “Option A — Caddy in HTTP mode (recommended)”Keep Caddy in front and have it listen on plain :80. Caddy still handles the path split, compression, security headers, and SSE tuning. Plain :80 is the default CADDY_SITE_ADDRESS, so the only rule is: do not set CADDY_SITE_ADDRESS to a domain — a domain value switches Caddy into Let’s Encrypt mode, which is exactly what you must avoid behind a tunnel.
Breeze .env:
# Plain HTTP — let Cloudflare handle TLS at the edge.# Do NOT set this to a domain when behind a tunnel.CADDY_SITE_ADDRESS=:80Tunnel ingress (/opt/breeze/cloudflared/config.yml):
ingress: - hostname: breeze.example.com service: http://caddy:80 - service: http_status:404In the Cloudflare dashboard, the public hostname’s Service is HTTP → caddy:80. No origin certificate is needed.
Option B — Bypass Caddy
Section titled “Option B — Bypass Caddy”Point the tunnel straight at the api and web containers and replicate the path split in the tunnel’s ingress rules. cloudflared evaluates rules top to bottom, first match wins.
Tunnel ingress (/opt/breeze/cloudflared/config.yml):
ingress: # API surface: REST, agents, websockets, health, metrics, oauth, activation - hostname: breeze.example.com path: '^/(api|oauth|\.well-known|s|i|activate|health|metrics|ready)(/.*)?$' service: http://api:3001 # Everything else → the dashboard - hostname: breeze.example.com service: http://web:4321 - service: http_status:404Prerequisites
Section titled “Prerequisites”- A domain on a Cloudflare account (the zone must be active on Cloudflare).
- A working Breeze stack reachable internally on the Caddy container.
- The bundled
tunnel(cloudflared) service fromdeploy/docker-compose.prod.yml, or your owncloudflaredinstall.
-
Create the tunnel in the Cloudflare dashboard
Go to Zero Trust → Networks → Tunnels → Create a tunnel, choose Cloudflared, and name it (for example
breeze). Cloudflare generates a tunnel token. Copy it. -
Add a public hostname
In the tunnel’s Public Hostname tab, add your Breeze hostname (for example
breeze.example.com) and point its Service at the Caddy container. The service type must be HTTP, not HTTPS — see TLS Termination above.Service type: HTTPURL: caddy:80In the bundled compose,
cloudflaredandcaddyshare thebreezenetwork, socaddy:80resolves directly. If you runcloudflaredon the host instead, usehttp://localhost:80. (Using Option B? Point the service atapi:3001/web:4321per the ingress rules instead.) -
Provide the tunnel credentials
The bundled
tunnelservice reads its config from/opt/breeze/cloudflared/config.yml:tunnel: <TUNNEL_ID>credentials-file: /etc/cloudflared/<TUNNEL_ID>.jsoningress:- hostname: breeze.example.comservice: http://caddy:80- service: http_status:404Place the matching
<TUNNEL_ID>.jsoncredentials file (downloaded from Cloudflare) in the same directory. SetCLOUDFLARED_IMAGE_REFin your.envto a digest-pinnedcloudflaredimage. -
Tell Breeze it sits behind a proxy
Because every request now arrives from
cloudflared→ Caddy, both layers must be told which hop to trust for the real client IP. The traffic chain iscloudflared (172.30.0.10) → caddy (172.30.0.11) → api.In your
.env:Terminal window # Caddy trusts the cloudflared hop for CF-Connecting-IP / X-Forwarded-ForCADDY_TRUSTED_PROXIES=172.30.0.10/32CADDY_CLIENT_IP_HEADERS=CF-Connecting-IP X-Forwarded-For# The API trusts the Caddy hopTRUST_PROXY_HEADERS=trueTRUSTED_PROXY_CIDRS=172.30.0.11/32Using Option B (no Caddy)? The API’s immediate upstream is now
cloudflared, so setTRUSTED_PROXY_CIDRS=172.30.0.10/32and omit theCADDY_*variables. -
Start the stack
Terminal window docker compose up -dcloudflaredconnects outbound to Cloudflare and registers the hostname. You can now close all inbound ports on the host firewall. -
Verify
Terminal window curl https://breeze.example.com/healthA
200confirms traffic is flowing Internet → Cloudflare → tunnel → Caddy → API.
Restricting the Dashboard with Cloudflare Access
Section titled “Restricting the Dashboard with Cloudflare Access”Breeze has no built-in IP allowlist for the dashboard, so IP or identity restrictions belong at the proxy. With a tunnel already in place, Cloudflare Access is the cleanest way to gate the admin UI while leaving agent traffic open to the internet.
The key constraint: agents connect from wherever your managed machines live (roaming laptops, customer sites, dynamic IPs), so you cannot allowlist the agents. Instead, protect everything except the agent paths.
-
Create an Access application
In Zero Trust → Access → Applications, add a Self-hosted application for
breeze.example.com. -
Add a bypass policy for agent paths
Agents and remote sessions must reach these paths without an Access login. Add an Access policy with action Bypass (source: Everyone) scoped to the agent paths, or define the Access application path so it excludes them:
/api/v1/agents/*— agent REST (enroll, heartbeat, logs)/api/v1/agent-ws/*— agent WebSocket (the live connection)/api/v1/desktop-ws,/api/v1/tunnel-ws,/api/v1/remote/sessions— remote desktop / terminal
-
Require identity for everything else
Add an Allow policy on the application that requires your identity provider (Google Workspace, Okta, one-time PIN to an email domain, etc.). Now the dashboard and dashboard API require an Access login, while agents keep connecting normally.
Troubleshooting
Section titled “Troubleshooting”Client IP shows as a Cloudflare address in audit logs / rate limits
- Confirm
CADDY_TRUSTED_PROXIESlists thecloudflaredhop andCADDY_CLIENT_IP_HEADERSincludesCF-Connecting-IP. - Confirm
TRUST_PROXY_HEADERS=trueandTRUSTED_PROXY_CIDRSlists the Caddy hop.
API container won’t start after enabling proxy trust
- In production,
TRUST_PROXY_HEADERS=truerequires a non-emptyTRUSTED_PROXY_CIDRS. Set it to the Caddy container’s CIDR (default172.30.0.11/32).
Remote desktop fails behind the tunnel
- Expected if TURN runs on the same host. The tunnel can’t carry UDP. Move TURN to a separate VPS — see TURN Server.
Agents can’t connect after adding Cloudflare Access
- The Access bypass for
/api/v1/agents/*and/api/v1/agent-ws/*is missing or too narrow. Agents have no browser session and will be blocked by an identity policy.