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=5349fingerprintlt-cred-mechstatic-auth-secret=YOUR_TURN_SECRET_HERErealm=breeze.localrelay-threads=2max-allocations-quota=100min-port=49152max-port=49252no-clilog-file=stdoutverboseexternal-ip=YOUR_PUBLIC_IP_HEREEOFReplace YOUR_TURN_SECRET_HERE and YOUR_PUBLIC_IP_HERE with your actual values.
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_REALM=breeze.local2. 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+UDP 49152–65535 — relay port range (default, can be tightened in
docker/turnserver.conf)
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 (
GET /remote/ice-servers). - It generates a temporary username (Unix timestamp + random nonce) and computes an HMAC-SHA1 signature using
TURN_SECRET. - The credential pair (username + HMAC password) is returned to both the viewer and agent.
- Credentials expire after a configurable TTL (default: 24 hours).
- coturn validates incoming TURN allocations against the same shared secret.
Clients never see TURN_SECRET — they only receive short-lived derived credentials.
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 for
realmmismatch between coturn config andTURN_REALM
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 - Consider enabling bandwidth limits in coturn:
max-bps=5000000(per session)
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 (check
GET /remote/ice-serversresponse)