TURN Server
WebRTC remote desktop and terminal sessions need a TURN relay when a direct peer-to-peer connection is not possible — symmetric NAT, restrictive corporate firewalls, or carrier-grade NAT all prevent direct connections. Without TURN, these sessions will fail for affected users.
The Breeze docker-compose.yml includes a bundled coturn container that works when the server has a public IP. However, if your Breeze stack runs behind Cloudflare Tunnel (or any other tunnel/proxy that only handles HTTP), TURN must run on a separate host because TURN uses UDP, which tunnels cannot carry.
Architecture Options
Section titled “Architecture Options”| Setup | TURN Location | When to use |
|---|---|---|
| Bundled | Same host as Breeze | Server has a public IP with UDP ports open |
| External | Dedicated VPS | Breeze is behind Cloudflare Tunnel, NAT, or a load balancer that doesn’t pass UDP |
External TURN Server Setup
Section titled “External TURN Server Setup”This guide uses a DigitalOcean droplet, but any VPS provider (Hetzner, Vultr, Linode, AWS Lightsail) works identically.
Requirements
Section titled “Requirements”- A VPS with a public IPv4 address
- Ubuntu 22.04+ or Debian 12+
- TCP+UDP ports open: 3478 and 49152–49252 (relay range)
- SSH access
Provision the server
Section titled “Provision the server”# Generate a TURN shared secretTURN_SECRET=$(openssl rand -hex 32)echo "TURN_SECRET=${TURN_SECRET}"
# Create the dropletdoctl compute droplet create breeze-coturn \ --region sfo3 \ --size s-1vcpu-512mb-10gb \ --image ubuntu-24-04-x64 \ --ssh-keys YOUR_SSH_KEY_ID \ --tag-names breeze,coturn \ --wait- Create a 512 MB+ VPS with Ubuntu 24.04
- Note the public IP address
- Generate a shared secret:
openssl rand -hex 32
Install and configure coturn
Section titled “Install and configure coturn”SSH into the server and run:
# Install coturnapt-get update && apt-get install -y coturn
# Enable the servicesed -i 's/^#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturnWrite the configuration file:
cat > /etc/turnserver.conf <<'EOF'listening-port=3478tls-listening-port=5349fingerprintuse-auth-secretstatic-auth-secret=YOUR_TURN_SECRET_HERErealm=breeze.localrelay-threads=2user-quota=4total-quota=1000max-bps=5000000no-tcp-relayno-multicast-peers# Relay only to public peers. Refused: loopback, private/VPC, CGNAT# (100.64/10, also used by tailnets), link-local (including the cloud metadata# address 169.254.169.254), documentation/benchmark, 6to4/Teredo, multicast and# reserved ranges, for IPv4 and IPv6. coturn refuses the unspecified address# itself. Never list `::` or `0.0.0.0` on its own, or start an IPv6 range at# `::`: coturn reads an all-zero bound as "no bound", so such an entry refuses# every peer and no relayed session can connect.# Never add this relay's own public IP (the external-ip) to this list: when# both peers are relayed through this server, relay-to-relay traffic targets# that address and would be refused.denied-peer-ip=0.0.0.0-0.255.255.255denied-peer-ip=10.0.0.0-10.255.255.255denied-peer-ip=100.64.0.0-100.127.255.255denied-peer-ip=127.0.0.0-127.255.255.255denied-peer-ip=169.254.0.0-169.254.255.255denied-peer-ip=172.16.0.0-172.31.255.255denied-peer-ip=192.0.0.0-192.0.0.255denied-peer-ip=192.0.2.0-192.0.2.255denied-peer-ip=192.88.99.0-192.88.99.255denied-peer-ip=192.168.0.0-192.168.255.255denied-peer-ip=198.18.0.0-198.19.255.255denied-peer-ip=198.51.100.0-198.51.100.255denied-peer-ip=203.0.113.0-203.0.113.255denied-peer-ip=224.0.0.0-239.255.255.255denied-peer-ip=240.0.0.0-255.255.255.255denied-peer-ip=::1denied-peer-ip=64:ff9b:1::-64:ff9b:1:ffff:ffff:ffff:ffff:ffffdenied-peer-ip=2001::-2001:1ff:ffff:ffff:ffff:ffff:ffff:ffffdenied-peer-ip=2002::-2002:ffff:ffff:ffff:ffff:ffff:ffff:ffffdenied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffffdenied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffffdenied-peer-ip=ff00::-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff# IPv4-mapped IPv6 (::ffff:0:0/96) and the NAT64 well-known prefix# (64:ff9b::/96) are IPv6 spellings of IPv4 addresses. No legitimate peer uses# them, and relay-to-relay is unaffected (it targets the relay's own address).denied-peer-ip=::ffff:0:0-::ffff:ffff:ffffdenied-peer-ip=64:ff9b::-64:ff9b::ffff:ffffmin-port=49152max-port=49252no-clilog-file=stdoutexternal-ip=YOUR_PUBLIC_IP_HEREEOFReplace YOUR_TURN_SECRET_HERE and YOUR_PUBLIC_IP_HERE with your actual values.
The denied-peer-ip lines keep the relay from reaching anything but public
peers: loopback, private/VPC networks, CGNAT and tailnet addresses, link-local
(including the cloud metadata address 169.254.169.254), multicast and
reserved ranges. no-tcp-relay is safe to keep: Breeze’s WebRTC sessions only
use UDP relay allocations. Two rules to follow when you adjust the list:
- Never deny the relay’s own public IP (your
external-ip). When both peers are relayed through this server, relay-to-relay traffic targets that address, and denying it breaks those sessions. - Keep the IPv4-mapped (
::ffff:0:0/96) and NAT64 (64:ff9b::/96) lines. They refuse IPv6 spellings of IPv4 addresses, which no legitimate peer uses, and they do not affect relay-to-relay traffic.
tls-listening-port=5349 above does nothing on its own: coturn skips the TLS
listener without complaint unless you also add cert= and pkey= pointing at
a certificate readable by the user coturn runs as, open TCP 5349 in the
firewall, and set TURN_TLS_HOST (the hostname on that certificate) in
Breeze’s .env so the API advertises turns:. Remember that coturn does not
reload the certificate — restart it on every renewal.
Start the service:
systemctl enable coturnsystemctl restart coturnsystemctl status coturnConfigure the firewall
Section titled “Configure the firewall”doctl compute firewall create \ --name breeze-coturn-fw \ --droplet-ids YOUR_DROPLET_ID \ --inbound-rules "protocol:tcp,ports:22,address:0.0.0.0/0 \ protocol:tcp,ports:3478,address:0.0.0.0/0 \ protocol:udp,ports:3478,address:0.0.0.0/0 \ protocol:tcp,ports:49152-49252,address:0.0.0.0/0 \ protocol:udp,ports:49152-49252,address:0.0.0.0/0" \ --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0 \ protocol:udp,ports:all,address:0.0.0.0/0 \ protocol:icmp,address:0.0.0.0/0"ufw allow 22/tcpufw allow 3478/tcpufw allow 3478/udpufw allow 49152:49252/tcpufw allow 49152:49252/udpufw enableVerify connectivity
Section titled “Verify connectivity”From your local machine:
# Check TCP port is reachablenc -z -w 3 YOUR_TURN_IP 3478
# Check UDP port is reachablenc -u -z -w 3 YOUR_TURN_IP 3478
# Check the service is runningssh root@YOUR_TURN_IP systemctl status coturnConfigure Breeze to Use External TURN
Section titled “Configure Breeze to Use External TURN”1. Update .env
Section titled “1. Update .env”Add or update these variables in your Breeze .env file:
TURN_HOST=YOUR_TURN_IPTURN_PORT=3478TURN_SECRET=YOUR_TURN_SECRET_HERETURN_CREDENTIAL_TTL_SECONDS=600TURN_REALM only configures the bundled coturn container’s --realm flag in docker-compose.yml — the API never reads it when generating credentials, so it has no effect here. Set the realm directly in your external coturn’s own config instead.
2. Restart the stack
Section titled “2. Restart the stack”The bundled coturn container is behind a Docker Compose profile and does not start by default. If you previously enabled it via COMPOSE_PROFILES=turn in your .env, remove that line so only your external TURN server is used.
docker compose pull api webdocker compose up -dThe API will now generate TURN credentials pointing to your external TURN server.
Using the Bundled TURN Server
Section titled “Using the Bundled TURN Server”If your Breeze server has a public IP (not behind a tunnel), the bundled coturn container works out of the box. Set these in .env:
TURN_HOST=YOUR_SERVER_PUBLIC_IPTURN_SECRET=$(openssl rand -hex 32)Then start Breeze with the turn profile enabled:
docker compose --profile turn up -dThe bundled coturn runs with network_mode: host, so it needs direct access to the network — no port mapping is required, but the host firewall must allow:
- TCP+UDP 3478 — TURN listening port (TCP is required for clients behind restrictive firewalls)
- TCP 5349 — TURN over TLS (
turns:), only once you have configured a certificate (see below) - TCP+UDP 49152–65535 — relay port range (default, can be tightened in
docker/turnserver.conf)
TURN over TLS (turns:)
Section titled “TURN over TLS (turns:)”The bundled coturn is started with --tls-listening-port=5349, but coturn
silently skips the TLS listener when it has no certificate — so out of the
box only 3478 listens. TURNS matters for reachability, not confidentiality
(WebRTC media is already end-to-end encrypted with DTLS-SRTP): turns: over
TCP/5349 is what gets through customer networks that block UDP or allow only a
short list of TCP ports.
To enable it, set in .env:
# Directory on the host containing cert.pem and privkey.pemTURN_TLS_DIR=/opt/breeze/coturn-tls# The hostname ON that certificate (not the bare IP in TURN_HOST)TURN_TLS_HOST=turn.your-domain.example.com# TURN_TLS_PORT=5349TURN_TLS_DIR is bind-mounted read-only at /etc/coturn/tls, and coturn is
given --cert/--pkey only when both files are readable. Leave TURN_TLS_DIR
unset and nothing changes — except that the container now logs why 5349 is
not listening instead of saying nothing. An expired or unparseable certificate
still enables the listener (coturn binds it either way) but logs a loud
WARNING at startup, since every handshake against it will fail.
The API also warns at startup when only one half is configured — a
TURN_TLS_HOST with no TURN_TLS_DIR advertises a turns: URL that nothing
is listening for, and a TURN_TLS_DIR with no TURN_TLS_HOST brings up a
listener no client is ever told about.
The API advertises turns: only when TURN_TLS_HOST is set, because a
certificate cannot validate against the bare public IP that TURN_HOST holds.
Point that hostname’s DNS at the TURN server.
Relay Port Range Sizing
Section titled “Relay Port Range Sizing”Each active WebRTC session uses one relay allocation (one UDP port). The default range in the bundled config is 49152–65535 (16,383 ports). For an external dedicated server, a tighter range reduces firewall surface:
| Expected concurrent sessions | Recommended range | Ports |
|---|---|---|
| Up to 50 | 49152–49202 | 50 |
| Up to 100 | 49152–49252 | 100 |
| Up to 500 | 49152–49652 | 500 |
Edit min-port and max-port in the coturn config and match the firewall rule.
How Credentials Work
Section titled “How Credentials Work”Breeze uses the TURN time-limited credential mechanism (RFC 5766 long-term credentials with a shared secret):
- The API receives a request for ICE servers from a desktop-session or viewer-token endpoint.
- It verifies the caller is bound to an active desktop session, then generates a temporary username containing the Unix expiry, user/session/device scope, and random nonce.
- The credential pair (username + HMAC password) is returned to both the viewer and agent.
- Credentials expire after a configurable TTL (default: 10 minutes, clamped to 1–15 minutes).
- coturn validates incoming TURN allocations against the same shared secret via
use-auth-secret.
Clients never see TURN_SECRET — they only receive short-lived derived credentials.
From v0.118.0, credentials are not issued for sessions that have ended, and each user may request 30 credentials per 10 minutes (429 above that). Tune the limit with TURN_CREDENTIAL_MINT_LIMIT_PER_WINDOW and TURN_CREDENTIAL_MINT_WINDOW_SECONDS; 0 turns it off. See Environment Variables.
Troubleshooting
Section titled “Troubleshooting”Remote desktop fails with “ICE failed”
- Verify the TURN server is reachable:
nc -z -w 3 TURN_IP 3478(TCP) andnc -u -z -w 3 TURN_IP 3478(UDP) - Check coturn logs:
ssh root@TURN_IP journalctl -u coturn -f - Confirm
TURN_HOSTin.envmatches the TURN server’s public IP exactly - Ensure the firewall allows both TCP and UDP on port 3478 and the relay port range — Breeze advertises
transport=tcpandtransport=udpTURN URLs
Coturn starts but clients can’t allocate
- Check
external-ipin/etc/turnserver.conf— it must be the server’s actual public IP - Verify
static-auth-secretmatchesTURN_SECRETin Breeze’s.env - Check the
realmvalue in your external coturn’s own config — Breeze’sTURN_REALMonly configures the bundled coturn container and has no effect on an external one
High bandwidth usage on the TURN server
- TURN relays all media when direct P2P fails. Each desktop session at 5 Mbps = ~2.25 GB/hour
- Monitor with
vnstator DigitalOcean’s bandwidth graphs - Keep
max-bps,user-quota,total-quota, and a narrow relay port range enabled. Raise them only after adding bandwidth alerts and provider-level egress budgets.
WebSocket fallback is active instead of WebRTC
- The viewer falls back to WebSocket when WebRTC negotiation fails entirely
- Check browser console for ICE errors
- Ensure both STUN and TURN candidates are being generated from a session-scoped ICE endpoint response