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-tcp-relay
no-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.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.0.2.0-192.0.2.255
denied-peer-ip=192.88.99.0-192.88.99.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=198.51.100.0-198.51.100.255
denied-peer-ip=203.0.113.0-203.0.113.255
denied-peer-ip=224.0.0.0-239.255.255.255
denied-peer-ip=240.0.0.0-255.255.255.255
denied-peer-ip=::1
denied-peer-ip=64:ff9b:1::-64:ff9b:1:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=2001::-2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=2002::-2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-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:ffff
denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff
min-port=49152
max-port=49252
no-cli
log-file=stdout
external-ip=YOUR_PUBLIC_IP_HERE
EOF

Replace 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:

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

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:

Terminal window
# Directory on the host containing cert.pem and privkey.pem
TURN_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=5349

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


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.

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.


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