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.

SetupTURN LocationWhen to use
BundledSame host as BreezeServer has a public IP with UDP ports open
ExternalDedicated VPSBreeze 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
max-allocations-quota=100
user-quota=4
total-quota=1000
max-bps=5000000
no-loopback-peers
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_REALM=breeze.local
TURN_CREDENTIAL_TTL_SECONDS=600

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 sessionsRecommended rangePorts
Up to 5049152–4920250
Up to 10049152–49252100
Up to 50049152–49652500

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 for realm mismatch between coturn config and TURN_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 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