Skip to content

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.

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

This guide uses a DigitalOcean droplet, but any VPS provider (Hetzner, Vultr, Linode, AWS Lightsail) works identically.

  • 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
Terminal window
# Generate a TURN shared secret
TURN_SECRET=$(openssl rand -hex 32)
echo "TURN_SECRET=${TURN_SECRET}"
# Create the droplet
doctl 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

SSH into the server and run:

Terminal window
# Install coturn
apt-get update && apt-get install -y coturn
# Enable the service
sed -i 's/^#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn

Write the configuration file:

Terminal window
cat > /etc/turnserver.conf <<'EOF'
listening-port=3478
tls-listening-port=5349
fingerprint
use-auth-secret
static-auth-secret=YOUR_TURN_SECRET_HERE
realm=breeze.local
relay-threads=2
user-quota=4
total-quota=1000
max-bps=5000000
no-multicast-peers
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.0.0.0-192.0.0.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=198.18.0.0-198.19.255.255
denied-peer-ip=224.0.0.0-239.255.255.255
denied-peer-ip=240.0.0.0-255.255.255.255
min-port=49152
max-port=49252
no-cli
log-file=stdout
verbose
external-ip=YOUR_PUBLIC_IP_HERE
EOF

Replace YOUR_TURN_SECRET_HERE and YOUR_PUBLIC_IP_HERE with your actual values.

Start the service:

Terminal window
systemctl enable coturn
systemctl restart coturn
systemctl status coturn
Terminal window
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"

From your local machine:

Terminal window
# Check TCP port is reachable
nc -z -w 3 YOUR_TURN_IP 3478
# Check UDP port is reachable
nc -u -z -w 3 YOUR_TURN_IP 3478
# Check the service is running
ssh root@YOUR_TURN_IP systemctl status coturn

Add or update these variables in your Breeze .env file:

Terminal window
TURN_HOST=YOUR_TURN_IP
TURN_PORT=3478
TURN_SECRET=YOUR_TURN_SECRET_HERE
TURN_CREDENTIAL_TTL_SECONDS=600

TURN_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.

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.

Terminal window
docker compose pull api web
docker compose up -d

The API will now generate TURN credentials pointing to your external 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:

Terminal window
TURN_HOST=YOUR_SERVER_PUBLIC_IP
TURN_SECRET=$(openssl rand -hex 32)

Then start Breeze with the turn profile enabled:

Terminal window
docker compose --profile turn up -d

The 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)

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.


Breeze uses the TURN time-limited credential mechanism (RFC 5766 long-term credentials with a shared secret):

  1. The API receives a request for ICE servers from a desktop-session or viewer-token endpoint.
  2. 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.
  3. The credential pair (username + HMAC password) is returned to both the viewer and agent.
  4. Credentials expire after a configurable TTL (default: 10 minutes, clamped to 1–15 minutes).
  5. 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.


Remote desktop fails with “ICE failed”

  • Verify the TURN server is reachable: nc -z -w 3 TURN_IP 3478 (TCP) and nc -u -z -w 3 TURN_IP 3478 (UDP)
  • Check coturn logs: ssh root@TURN_IP journalctl -u coturn -f
  • Confirm TURN_HOST in .env matches 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=tcp and transport=udp TURN URLs

Coturn starts but clients can’t allocate

  • Check external-ip in /etc/turnserver.conf — it must be the server’s actual public IP
  • Verify static-auth-secret matches TURN_SECRET in Breeze’s .env
  • Check the realm value in your external coturn’s own config — Breeze’s TURN_REALM only 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 vnstat or 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