Skip to main content
This page adds a TURN relay to a self-hosted instance, for the transfers that cannot go direct. Some home, office, and mobile networks do not let two devices connect to each other directly, which is common behind symmetric NAT or carrier-grade NAT. A relay carries the stream for those, still end-to-end encrypted. Without one, those transfers fail, and every other transfer still connects directly. You have two options: a managed TURN service (recommended) or a self-hosted coturn relay.

The short version

With a Cloudflare account, create a TURN key, put its two values in .env, and run docker compose up -d. Running coturn yourself instead takes a Linux host with a public IP address, a domain, a TLS certificate (unless you set TURN_TLS=off), and open UDP ports. Either way, check the result with a transfer that may only use the relay. Cloudflare’s Realtime TURN service runs on Cloudflare’s global anycast network. It needs no public IP, no UDP port range, and no TLS certificates on your side. Cloudflare charges for relayed data beyond a free allowance, and its TURN FAQ has the current figures.
1

Create a TURN key

In the Cloudflare dashboard, open Realtime > TURN Server and create a key. Copy the Turn Token ID and its API token (the token is shown only once).
2

Set the server environment

In .env:
.env
The signaling server mints credentials from Cloudflare that last 24 hours, and caches them for 5 minutes. These variables take precedence over the coturn variables below, so you do not need to run coturn. Set both: with only one of the pair, the server skips Cloudflare and logs one line saying so. A server image of version 1.11.7 or earlier logs nothing about it.
3

Recreate the server

Terminal
Both variables are read once, when the server starts. Then check the result under Verify.

Self-hosting with coturn

Prefer to run the relay yourself? Leave the Cloudflare variables unset and configure coturn instead.
The bundled coturn service needs a Linux host with a public IP address, a domain name, and TLS certificates, unless you set TURN_TLS=off. It uses host networking (network_mode: host), which only works on Linux. On macOS or Windows with Docker Desktop, run coturn separately or on a Linux VM.

Set up coturn

1

Create the coturn config

Terminal
This file holds your shared secret, so it is never shipped and you always create it yourself.
Do not skip this. Docker creates a directory at that path instead, and coturn then starts anyway and sits there as running in docker compose ps, on built-in defaults with no auth secret. Relaying silently fails for exactly the peers TURN exists to serve. The giveaway is Default realm: localdomain in docker compose logs coturn.Recovering needs rmdir coturn/turnserver.conf first: copying onto a directory succeeds and writes the file inside it, so the obvious fix appears to work and does not.
2

Set the secret, the realm, and the public address

Edit coturn/turnserver.conf and set:
  • static-auth-secret to a strong random value (for example, the output of openssl rand -hex 32)
  • realm to your TURN hostname (for example, turn.your-domain.com), the same value the server gets as TURN_DOMAIN in a later step. Give that hostname a DNS record that points at this machine.
  • external-ip to the machine’s public IP, if the interface itself holds a private address
external-ip is commented out in the example file, and uncommenting it is the normal case on AWS, GCP, Azure, and any VPS behind 1:1 NAT. Leave it commented out only when the public address is bound directly to the interface. Get it wrong and coturn advertises a relay candidate nobody can route to, so relayed transfers fail with no error message.
3

Add the TLS certificate

Copy your certificate and its key into a coturn/certs folder next to docker-compose.yml, as coturn/certs/fullchain.pem and coturn/certs/privkey.pem. In coturn/turnserver.conf, uncomment the two lines that name them:
coturn/turnserver.conf
The certificate mount is shipped commented out too. Uncomment it in docker-compose.yml so those paths exist inside the container:
docker-compose.yml
If you started from the curl quickstart rather than a clone, that means editing your downloaded docker-compose.yml. No certificate? Leave both lines commented out, skip the mount, and set TURN_TLS=off in the next step. Skip this step without it and the signaling server still advertises a turns: URL, pointing at a port that cannot complete a TLS handshake.
4

Match the server environment

In .env:
.env
The signaling server uses these to issue time-limited HMAC-SHA1 credentials for coturn. Credentials expire after 24 hours. Set both: with only one of the pair, the server hands out no coturn entries and logs one line saying so. Give TURN_DOMAIN as the hostname alone: with a scheme, a port or a path the coturn relay is off, and the server logs one line about that too. A server image of version 1.11.7 or earlier logs neither. Set TURN_TLS=off when coturn runs without a certificate: the server then hands out the stun: and turn: entries only.TURN_TLS needs a server image newer than 1.11.0 and a docker-compose.yml from 1.11.1 or later. An older copy of the file does not pass it to the server, so the turns: entry stays; download the current one first, as Update to the latest code describes.
5

Open firewall ports

On the host, open the following ports:These ports must be reachable directly. TURN is not HTTP, so it cannot be routed through the reverse proxy that fronts the web client and signaling server. Even on a one-domain deployment, coturn keeps its own ports and its own TLS certificate.
6

Start the stack with the TURN profile

Terminal
Create coturn/turnserver.conf first, as the first step describes. Starting the profile without it looks successful and is not.

Verify

Check that the signaling server returns TURN credentials:
Terminal
With coturn configured, the response contains turn: and turns: entries, plus a stun: entry pointing at your TURN host:
Output
With TURN_TLS=off the turns: entry is left out, and the response holds the first two. With Cloudflare configured the response is two entries rather than three, and urls is an array rather than a string. One holds stun:stun.cloudflare.com:3478 with no username or credential, and the other a turn: and a turns: URL with the credentials. See the API reference for the exact shape. When neither the Cloudflare variables nor TURN_SECRET / TURN_DOMAIN are set, the endpoint returns the Google public STUN servers only. That check proves the server hands out credentials, not that the relay carries data. A coturn with a wrong external-ip, a closed UDP range, or a bad certificate passes it. To prove the relay end to end, run a transfer that may only use the relay, between two machines:
Terminal
Each side prints this line once the relay carries the connection:
Output
A server that offers no relay stops the command at once instead:
Output
Hide my IP address in Floe Desktop makes the same test from the desktop app. Cloudflare credentials are minted upstream and then cached in memory for 5 minutes. Two consequences are worth planning around:
  • Both keys are read once at startup, so a changed key takes effect only after docker compose up -d (a restart, without Docker). The server starts with an empty cache, so the new key is used at once.
  • If the Cloudflare API starts failing, including after a key is deleted, the server keeps serving the last good copy for up to about 23 hours after it was minted. It logs Cloudflare TURN mint failed at most once every 5 minutes. Only then does it fall back to coturn, if that is configured, or to Google STUN. A broken key can look healthy for most of a day.

What still leaves your server

A relay does not keep every client away from Google. When a client cannot fetch connection details from your server, because the request is misrouted, rate limited, or the server is down, it falls back to two hard-coded Google STUN addresses rather than giving up. The web app, the CLI, and Floe Desktop all do this, and only a transfer that forces the relay stops instead. The web app’s /docs link sends visitors on to floe.one’s documentation. If your instance exists so that no traffic leaves your control, plan for both.

How credentials work

For coturn, the signaling server generates time-limited credentials using HMAC-SHA1. The username is {expiry_unix_timestamp}:floeuser and the password is base64(HMAC-SHA1(TURN_SECRET, username)). coturn validates these against the shared secret without needing a database of user accounts.

Your relay keeps the 2 GB cap

Running your own relay does not lift the 2 GB cap on a relayed transfer. The cap is compiled into the browser client, the CLI, and the desktop app, not read from your server, so raising it means building all three from modified source. See File size limits.

Operating a relay

Everything in this section is for managed Cloudflare TURN except the last part, which is for coturn.

Set a budget alert

Cloudflare can email you when your spending crosses a dollar amount you set. In the Cloudflare dashboard, go to Manage Account > Billing > Billable Usage and select Create budget alert. Cloudflare’s budget alerts documentation has the details. Pay-as-you-go accounts already have a default $10 alert; change its amount there or create your own (Cloudflare changelog). Know its limits before you rely on it:
  • It is account-wide. It adds up the usage-based spending of all your Cloudflare products, not the relay alone.
  • It is available on Pay-as-you-go accounts only.
  • It is informational and does not cap spending. The relay keeps carrying traffic after the email arrives.
  • It is not real time. Cloudflare processes usage once a day, so the email comes the day after your spending crosses the amount.

See usage per username

Cloudflare reports TURN usage per credential username, but only through its GraphQL analytics API. Its TURN analytics documentation covers the metrics, the username dimension and filter, and the API token permission a query needs. A username stands for one issuance, not one person. The server mints Cloudflare credentials at most once every 5 minutes, and every client that fetches them between two mints gets the same username. See the relay credentials reference for how long each set lasts and how the server caches it.

Revoke a credential that leaked once

When a single credential has leaked, for example in a pasted log or a screenshot, revoke its username:
Terminal
Replace <KEY_ID> and <API_TOKEN> with your CLOUDFLARE_TURN_KEY_ID and CLOUDFLARE_TURN_KEY_API_TOKEN values, and <username> with the username of the leaked entry. Cloudflare answers 204 when the credential is revoked; see its revoke documentation. Every client that fetched credentials while that username was current holds the same one, so revoking it revokes theirs too. If /api/turn-credentials still returns the username you revoked, restart the server (docker compose restart server) so it mints a new one at once, or wait up to 5 minutes for the next mint. A restart also forgets every room and room code and stops any CLI or Floe Desktop sender still waiting for its receiver; see Operations.

Rotate the key

Revoking handles a credential that leaked once. A leaked API token needs a new key, because revoking cannot touch it.
1

Create a new TURN key

In the Cloudflare dashboard, the same way as in the managed TURN setup.
2

Switch the server to it

Put its ID and token in .env and run docker compose up -d (without Docker, restart the server). The server starts minting with the new key at once. A plain docker compose restart does not apply a changed .env.
3

Delete the old key

Nothing more can be minted with it afterward.
Plan on credentials already issued keeping their own lifetime, which is 24 hours from minting for every one this server mints. Rotation does not stop someone collecting credentials in bulk from your server: it hands new-key credentials to anyone who asks. Block that source at your reverse proxy or firewall instead, and plan on what they already hold lasting up to 24 hours.

If you run coturn

The coturn setup copies the example file once. Later changes to coturn/turnserver.conf.example never reach a coturn/turnserver.conf that already exists.
The compose file pins coturn to coturn/coturn:4.15, so pulling the stack keeps you on that version. Moving to a newer coturn means changing the image line of the coturn service. The example file refuses to relay to private, loopback, link-local and other reserved addresses, and to multicast (its denied-peer-ip and no-multicast-peers lines). A coturn/turnserver.conf made from an older copy of the example lacks those lines: copy them in from the current example. If any device that uses the relay can be on the relay’s own private network (for example, the relay runs at home and you send or receive from a device in the same home), relayed connections to that device fail unless you uncomment the example’s allowed-peer-ip line and set it to that network’s IPv4 range. That line also lets everyone who gets credentials from your server reach that range through the relay, so prefer running the relay on a network of its own. Leave the line commented out when no device that uses the relay is on its network.

If something goes wrong

Last modified on November 3, 2026