Ingress: Cloudflare Tunnel
The rule everything else is built around
A Cloudflare Tunnel carries HTTP and WebSocket. It does not carry UDP, and WebRTC media is UDP. Putting a media server behind the tunnel means smuggling media around it somehow — every one of those workarounds breaks. Peer-to-peer media never has this problem, because media never goes near the tunnel at all. See Peer-to-Peer Media.
Signalling: Client → Cloudflare → Tunnel → Nginx → call-service
(WebSocket, same shape as chat)
Media: Client <========================================> Client
(WebRTC, direct, never touches Cloudflare)
What this buys
- No UDP port is ever opened.
- No service ever advertises its own address. There is deliberately no "advertised address" setting anywhere — that class of bug (correct on the server, wrong for every client, invisible to whatever the operator can test locally) was the single most expensive bug in an earlier design that used an SFU.
- One hostname. Signalling is a WebSocket path on the same gateway as everything else.
- Reachable from any network a client can reach the site from at all.
Reaching the other peer
| Case | What carries the media |
|---|---|
| Same LAN | Direct, host candidates |
| Different networks, ordinary NAT | Direct, STUN-discovered addresses |
| Symmetric / carrier-grade NAT | TURN relay, only if one is configured — otherwise no path exists |
STUN needs no tunnel and no port — the client dials a public STUN server
itself. TURN is optional and off by default; when an operator wants that last
category of network to work, they run a standard TURN server and name it with
TURN_URLS, TURN_USERNAME and TURN_CREDENTIAL.
It does not go through this tunnel, and it could not. A Cloudflare Tunnel carries HTTP and WebSocket; its edge terminates TLS on 443 and expects HTTP inside, so TURN over TLS — its own binary protocol — is refused before it arrives. cloudflared's TCP ingress needs cloudflared or WARP on the client side, which a browser cannot run. A self-run relay therefore needs a host with a public address of its own; it is reached outbound by both peers, so it still opens no port on the machine behind this tunnel. The full setup is TURN relay (coturn).
On a STUN-only deployment the last row is the accepted ceiling, and it is a narrower row than it looks. Most failures that present as "no path" are a lost candidate race rather than a genuine absence of one, so the client retries a broken link properly — backed-off ICE restarts, then up to three full connection rebuilds with fresh ports — before concluding anything. See Media. Only what survives all of that is this row.
Two layers, two jobs
Cloudflare Tunnel:
- Secure outbound tunnel
- Public ingress for HTTP and WebSocket
- No port forwarding, no direct public server exposure
Nginx / Traefik:
- Internal routing, load balancing
- WebSocket routing, rate limiting
- Service routing
cloudflared can run as its own Docker container (--profile public), or
an operator can point an existing tunnel they already run at
http://localhost:8080 — the gateway's published GATEWAY_PORT. Both work;
the difference is one line of tunnel config.
Route map
/ web client (apps/web, built static bundle)
/admin admin panel (apps/admin)
/api/v1/* REST, routed to the owning service
/ws/* WebSocket, routed to the owning service's gateway
Nginx matches the longest prefix, so / never shadows /admin, /api, or
/ws.