# Cloudflare Tunnel

Source: https://docs.gryt.chat/docs/deployment/cloudflare-tunnel

Host Gryt behind Cloudflare Tunnel (HTTP) + direct UDP (WebRTC media)

This setup uses Cloudflare Tunnel for **HTTPS/WSS** (server, SFU websocket) while keeping WebRTC **media UDP** direct-to-host.

## What Cloudflare can and can’t proxy

- **OK via Tunnel**:
  - Server HTTP + WebSocket (Socket.IO)
  - SFU websocket (HTTP upgraded to WSS at the edge)
- **Not OK via Tunnel**:
  - WebRTC media (UDP). You must open and forward `ICE_UDP_MUX_PORT` to the SFU host. It is one port.

### Important: Tunnel/proxy is only for the SFU WebSocket

Cloudflare Tunnel (and Cloudflare “orange cloud” proxying) only affects the **signaling/control** connection to the SFU over **HTTP/WSS**.
Your actual voice traffic (ICE + DTLS + SRTP) still flows **directly over UDP** to the SFU host.

That means:

- `SFU_PUBLIC_HOST` can point to a tunneled/proxied hostname (e.g. `wss://sfu.example.com`) because it’s only used for the WebSocket.
- Your ICE candidates must advertise **real, reachable IPs** for the SFU host (set `ICE_ADVERTISE_IP`).
- You must open the corresponding UDP ports on the SFU host firewall / security group (see below).

If `dig AAAA sfu.example.com` returns Cloudflare anycast IPs (often starting with `2606:4700:...`), that’s expected for a proxied hostname — but it is **not** where WebRTC UDP will go.

## Recommended compose stack

Use the self-contained hosting stack:

- `ops/deploy/host/compose.yml`
- `ops/deploy/host/.env` (create from `.env.example`)

## Configure

```bash
cd ops/deploy/host
cp .env.example .env
```

Edit `ops/deploy/host/.env` (minimum):

```bash
JWT_SECRET="replace-me" # openssl rand -base64 48
SFU_PUBLIC_HOST="wss://sfu.example.com"
CORS_ORIGIN="http://127.0.0.1:15738,https://app.gryt.chat"
GRYT_TRUSTED_PROXY_HOPS=1
```

### Why the proxy hops setting is in that list

The tunnel is a proxy, so without this every client arrives at the server wearing
the tunnel's address instead of their own. They all share one rate-limit bucket,
and the per-IP invite lockout applies to everyone at once — one person tripping a
limit throttles the server.

`1` is right here. Cloudflare appends the visitor's address to the right of
`x-forwarded-for`, the tunnel adds nothing after it, and the server counts from
that end. Anything a client puts in the header stays to the left, where it is
ignored.

Go to `2` only if you run your own reverse proxy *behind* the tunnel as well.
Leave it at the default of `0` and the setting does nothing; set it higher than
the number of proxies you really have and you are trusting a header the client
can write. There is more on this in
[Configuration → Running behind a proxy](https://docs.gryt.chat/docs/guide/configuration).

## Start

```bash
docker compose -f ops/deploy/host/compose.yml up -d --build
```

## Cloudflared routing (example)

Map hostnames to localhost origins:

- `api.gryt.example.com` → `http://127.0.0.1:5000` (WebSocket upgrade required)
- `sfu.example.com` → `http://127.0.0.1:5005` (WebSocket upgrade required)

Cloudflare Tunnel terminates TLS at the edge, so your public endpoints are `https://...` / `wss://...` even if the services on the host are plain HTTP.

Example `cloudflared` `config.yml`:

```yaml
tunnel: <your-tunnel-id>
credentials-file: /etc/cloudflared/<your-tunnel-id>.json

ingress:
  - hostname: api.gryt.example.com
    service: http://127.0.0.1:5000
    originRequest:
      noTLSVerify: true
      connectTimeout: 10s
      keepAliveTimeout: 90s
  - hostname: sfu.example.com
    service: http://127.0.0.1:5005
    originRequest:
      noTLSVerify: true
      connectTimeout: 10s
      keepAliveTimeout: 90s
  - service: http_status:404
```

## WebSocket connection stability

Cloudflare Tunnel may occasionally reset long-lived WebSocket connections during edge server rotation or tunnel reconnection. Gryt handles this automatically: the signaling server preserves voice state for up to 15 seconds during transient disconnects, and the client will recover without tearing down the SFU media connection if it is still alive. Users may briefly see a "Reconnecting" toast but voice/screenshare should continue uninterrupted.

The `originRequest` settings above help reduce spurious disconnects:

- **`noTLSVerify`** -- skip TLS verification for the local connection (the tunnel already terminates TLS at the edge).
- **`connectTimeout`** -- time to establish a TCP connection to the origin (default 30s, 10s is faster for local services).
- **`keepAliveTimeout`** -- idle timeout before discarding keepalive connections (default 90s). Gryt's Socket.IO sends pings every 15s, so connections should never go idle.

## DNS records: enable Proxied (orange cloud) for all hostnames

Every hostname routed through the tunnel **must** have its DNS record set to **Proxied** (orange cloud) in the Cloudflare dashboard. This includes the SFU hostname.

| Hostname | Type | Content | Proxy status |
|---|---|---|---|
| `api.gryt.example.com` | CNAME | `<tunnel-id>.cfargotunnel.com` | **Proxied** (orange) |
| `sfu.example.com` | CNAME | `<tunnel-id>.cfargotunnel.com` | **Proxied** (orange) |

<Callout type="warn">
**Don't grey-cloud the SFU hostname.** Even though WebRTC media is direct UDP,
the SFU **WebSocket** (port 5005) still goes through the tunnel. If you set `sfu.example.com` to
DNS-only (grey cloud), browsers won't be able to reach the SFU WebSocket and voice connections will fail.
</Callout>

## Upload size limit (100 MB)

Cloudflare enforces a **100 MB maximum request body size** on Free and Pro plans (200 MB on Business, 500 MB on Enterprise). File uploads that exceed this limit are rejected by Cloudflare's edge before they reach your server. Because Cloudflare's error response does not include your server's CORS headers, the browser reports a misleading **CORS error** instead of the real cause.

This affects all HTTP uploads routed through the tunnel — chat file attachments, avatars, and emoji uploads.

<Callout type="warn" title="Large file uploads fail silently">
If a user uploads a file larger than 100 MB through a Cloudflare Tunnel on a Free/Pro plan, they will see a generic CORS or network error. The request never reaches your Gryt server, so there will be no log entry on the server side.
</Callout>

### Alternatives for large uploads

If you need to support file uploads larger than 100 MB, consider using a different reverse proxy instead of (or alongside) Cloudflare Tunnel:

- **[Nginx Proxy Manager](https://nginxproxymanager.com/)** — web-based UI for managing Nginx reverse proxies with automatic Let's Encrypt certificates. Set `client_max_body_size` to your desired limit.
- **[Traefik](https://traefik.io/)** — cloud-native reverse proxy with automatic TLS. No default body size limit.
- **[Caddy](https://caddyserver.com/)** — simple reverse proxy with automatic HTTPS (see the [Docker Compose guide](https://docs.gryt.chat/docs/deployment/docker-compose#tls-with-caddy-recommended)). No default body size limit.

All three options handle TLS termination and have no inherent upload size restrictions, so the only limit will be what you configure on the Gryt server itself (default: 200 MB).

## Firewall / port forwarding (critical)

Open/forward UDP to the host running the `sfu` container.

Recommended (best success rate on restrictive networks):

- `ICE_UDP_MUX_PORT/udp` (default `3478/udp`)

Alternative:

- `ICE_UDP_MUX_PORT/udp` (default `3478/udp`). One port, and the tunnel does not carry it

If the SFU host is behind NAT or has multiple interfaces, set `ICE_ADVERTISE_IP` so it advertises the correct public IP in ICE candidates. For multi-network setups (e.g. LAN + public), both `ICE_ADVERTISE_IP` and `SFU_PUBLIC_HOST` accept comma-separated values — the client automatically pings each endpoint and picks the fastest one.

For IPv6-heavy client networks (common on mobile), make sure the SFU host has IPv6 and include the public IPv6 in `ICE_ADVERTISE_IP`, and allow inbound **IPv6 UDP** on the same media port(s).
