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
lt-cred-mech
static-auth-secret=YOUR_TURN_SECRET_HERE
realm=breeze.local
relay-threads=2
max-allocations-quota=100
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

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 (GET /remote/ice-servers).
  2. It generates a temporary username (Unix timestamp + random nonce) and computes an HMAC-SHA1 signature using TURN_SECRET.
  3. The credential pair (username + HMAC password) is returned to both the viewer and agent.
  4. Credentials expire after a configurable TTL (default: 24 hours).
  5. coturn validates incoming TURN allocations against the same shared 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
  • 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-servers response)