Voice and video
The node runs livekit-server (bundled in the image) as a supervised child process: it generates LiveKit’s API
key once (/data/keys/livekit.key, back it up with the volume; a lost file is simply regenerated) and its
configuration (/data/livekit.yaml) at every start, restarts it with a backoff when it crashes and stops it on
shutdown. /healthz reports "voice": "running", /readyz includes "livekit": true. Voice is end-to-end
encrypted (frame keys from the channel’s MLS group); the node and LiveKit only forward encrypted media (protocol:
VOICE.md). TAVRN_VOICE_ENABLED=false switches
voice off completely (no LiveKit process). All settings: Voice and video settings.
Signaling and media ports
Section titled “Signaling and media ports”Voice takes two paths: the signaling through the reverse proxy, the media directly to ports published by the container.
-
Signaling goes through the proxy like everything else:
wss://<public host>/rtc. tavrn checks the LiveKit token of every connection (signature, expiry, and that its holder may still join the channel or call) before it forwards it to LiveKit, which listens on127.0.0.1:7880inside the container only. Do not publish or proxy port 7880, and do not route/rtcanywhere but to tavrn. Browsers send the token as theaccess_tokenquery parameter; the desktop app’s native voice engine sends it asAuthorization: Bearer, so the proxy must pass that header through on the/rtcupgrade (see Native voice in the desktop app). -
Media does not go through the proxy. Publish and forward (router port forwarding to the host running the container, firewall open) these ports 1:1, the same numbers outside and inside, because LiveKit announces them:
What Port(s) Setting Media UDP 61000-61100TAVRN_RTC_UDP_PORT_RANGE(keep it outside the Linux ephemeral range 32768-60999, or random outgoing UDP sockets on the host block the publication)ICE over TCP (fallback when UDP is blocked) TCP 7881TAVRN_RTC_TCP_PORT(0 = off)TURN over UDP (optional) UDP 3478TAVRN_RTC_TURN_UDP_PORTwithTAVRN_RTC_TURN_ENABLED=trueTURN over TLS (optional) TCP 443on the proxy →127.0.0.1:5349TAVRN_RTC_TURN_TLS_PORT, through the proxydocker-compose.ymlpublishes the first two; the TURN lines are commented out. Firewall example:ufw allow 61000:61100/udp,ufw allow 7881/tcp(or your cloud’s security group). -
Capacity: every connected participant uses one or two ports of the range (one per WebRTC peer connection), so 101 ports carry about 50 participants node-wide. Widen the range for more.
Announced addresses
Section titled “Announced addresses”LiveKit tells clients where to send media (VOICE.md §10.2). Inside Docker the container only knows its private
addresses, so the node announces either the public addresses detected via STUN at start (IPv4 and IPv6, default)
or the ones you configure: TAVRN_RTC_EXTERNAL_IP (public IPv4) and TAVRN_RTC_ADVERTISE_IPS (more,
comma-separated). Both also accept hostnames (dynamic DNS, see below). Docker’s container addresses are never
announced. The startup log line LiveKit starting media=… lists what is announced and which ports are open;
compare it with the router.
- Clients in the same network as the node reach the public address only if the router supports NAT loopback
(“hairpin NAT”). Many home routers do not; then put the host’s LAN address into
TAVRN_RTC_ADVERTISE_IPS(e.g.TAVRN_RTC_ADVERTISE_IPS=192.168.0.23): clients add it as an extra candidate, LAN clients connect directly, others ignore it. IPv6 avoids the problem entirely (no NAT), see the next point. - IPv6: Docker’s default bridge network has no IPv6, and an IPv6-enabled network gives the container only a
private ULA (
fd…); announcing that is useless. Give the network an IPv6 subnet (the commentednetworks:block indocker-compose.yml, Docker 27+; a ULA is fine, Docker forwards the host’s global IPv6 to it for published ports), so Docker publishes the ports on the host’s global IPv6 as well, open them in the host firewall (IPv6 needs no port forwarding on the router, but the router’s IPv6 firewall must allow inbound traffic to the host for these ports), and let STUN detect the global address or add it toTAVRN_RTC_ADVERTISE_IPS. The node never announces link-local addresses (fe80::).
Connection test: in the web client, Settings → Voice & video → Test connection tries every path from the current device (UDP to each announced address, TCP, TURN) and says which failed and what to check. Run it once from inside the LAN and once from a phone on mobile data.
TURN (optional, for strict networks)
Section titled “TURN (optional, for strict networks)”Some networks (corporate Wi-Fi, some mobile carriers, hotel networks) allow nothing but outgoing HTTPS. For those,
enable LiveKit’s embedded TURN server: TAVRN_RTC_TURN_ENABLED=true. Clients get short-lived TURN credentials from
LiveKit and relay their media through it; the relay reaches LiveKit inside the container, so no router hairpin is
needed.
- TURN over UDP (
TAVRN_RTC_TURN_UDP_PORT, default 3478): publish and forward that UDP port. Helps behind symmetric NATs. - TURN over TLS on port 443 (
TAVRN_RTC_TURN_TLS_PORT, default 5349): clients connect toturns:<TAVRN_RTC_TURN_DOMAIN>:443; the proxy terminates TLS and forwards plain TCP, see TURN over TLS through the proxy. - Relays only go to LiveKit itself (loopback); the TURN server refuses every other private address, so it cannot be
used to reach other machines in your network. Each participant may hold at most 12 relay allocations. The relay
sockets use
TAVRN_RTC_TURN_RELAY_PORT_RANGEinside the container (not published).
Dynamic addresses and proxies like Cloudflare
Section titled “Dynamic addresses and proxies like Cloudflare”Media ports cannot be proxied: HTTP reverse proxies and CDNs like Cloudflare forward neither UDP nor arbitrary TCP
ports, so the media path must reach your host directly. Keep the web/API//rtc hostname behind the proxy if you
like, but use direct (DNS-only, “grey cloud”) records for media.
With a home connection whose address changes:
- Hostname entries with dynamic DNS:
TAVRN_RTC_EXTERNAL_IP=home.dyndns.example(and/or hostnames inTAVRN_RTC_ADVERTISE_IPS). The node resolves them (A and AAAA) at start and every minute, or after the record’s TTL if longer, announces their public addresses (private ones only withTAVRN_RTC_ADVERTISE_PRIVATE=true) and restarts LiveKit when they change; calls reconnect within the grace period. Give the record a low TTL (60–300 s) and keep it unproxied. If a name does not resolve the node keeps its last known addresses; if none ever resolved,/healthzshowsvoice_problemand LiveKit falls back to the STUN detection. - TURN with a hostname:
TAVRN_RTC_TURN_ENABLED=truewith TURN over TLS, clients useturns:turn.<host>:443and resolve that name themselves at every reconnect, so a new address takes effect at the next reconnect without the node noticing. Giveturn.<host>a low TTL; it must reach your TLS-terminating stream proxy directly (not through an HTTP-only CDN). This is the most robust path for rotating addresses and strict networks at once.
Limits and dropped connections
Section titled “Limits and dropped connections”Settings and entitlements limit voice quality and channel sizes; this section also explains the traffic a channel causes and what happens when a connection drops.
- Limits:
TAVRN_VOICE_MAX_BITRATE_KBPS,TAVRN_VOICE_MAX_PARTICIPANTS,TAVRN_SCREENSHARE_*,TAVRN_CAMERA_*, and the entitlement defaultsTAVRN_VOICE_CHANNEL_MAX_PARTICIPANTS,TAVRN_STREAM_*,TAVRN_CAMERA_MAX_HEIGHT. - Audio traffic: every listener receives what a speaker publishes (the media server forwards, it does not
transcode), so a channel’s bitrate is paid once upstream per speaker and once downstream per speaker and listener.
Opus with DTX sends almost nothing during silence; in-band FEC (announced as
limits.audio_fec) adds a little redundancy against packet loss. The music preset (stereo, 192 kbps, no DTX) costs about 86 MB per hour for every speaker and listener; lowerTAVRN_VOICE_MAX_BITRATE_KBPSto 128 or 64 on nodes with metered traffic. - Dropped connections: a participant whose connection breaks stays in the channel as “reconnecting” for
TAVRN_VOICE_RECONNECT_GRACE_SECS(default 45) while the client rejoins by itself; a join from another device of the same user replaces the first. - Screen sharing from the Android app needs nothing to configure: the app publishes the screen as a second
LiveKit participant of the same device (
<user>/<device>#screen, protocol VOICE.md §4b), which the node admits under the screen share settings and thestreampermission, counts as its device (no seat of a user limit) and removes together with the device. It is end-to-end encrypted like every other track. In LiveKit’s own logs and room listings it appears as an extra participant. - Several instances: Voice and video with several instances.
Moved members
Section titled “Moved members”A moderator with Move members can move a member into a voice channel the member could not open by themselves
(protocol: VOICE.md §7 “Moved participations”). The move decides, not the member’s permissions: for 60 seconds the
member may join that channel even without Connect or View channel and above its user limit, and while they stay
in it they see the channel, its text and its encryption group like a member who may view it. Everything else follows
their roles as usual (speaking, streaming and writing need their own permissions; a timeout, a ban and
TAVRN_VOICE_MAX_PARTICIPANTS still refuse, and the channel’s age gate still applies). Leaving, being removed, the
end of the reconnect grace period or a join elsewhere ends it: the channel disappears again unless the member’s
permissions show it, and they cannot come back by themselves. The API marks such participants with moved: true.
There is nothing to configure; with several instances the leader announces the moved members to every instance.
Session timer and channel status
Section titled “Session timer and channel status”Every voice state (GET /rooms/{id}/voice, GET /users/me/voice, the join answer and voice.state) carries
session_started_at: when the channel’s current session began, that is when somebody connected to the empty
channel; it stays while anybody is connected or reconnecting (whoever that is) and goes with the last participant.
Clients show a timer from it that is right for late joiners and for people outside the call. The node keeps it in
memory with the participants: a restart restarts LiveKit and drops everybody anyway, so there is nothing to persist
(protocol: VOICE.md §5).
Voice channels also carry a status line (at most 128 characters, protocol VOICE.md §2a): members with the
permission Set voice channel status (voice_status, bit 38) change it at any time through the channel update
route without Manage channels (audited as channel.voice_status, announced with channel.update). At the first
start with this release the bit is granted once to every role that holds Manage channels (fixup marker
fixup.voice_status in node_settings); rooms hand it to other roles or to @everyone as they like. Nothing to
configure.
Voice activity in the room list
Section titled “Voice activity in the room list”Clients show who is in voice where (also friends in voice, and a badge on rooms with voice activity) without opening
every room: GET /api/v1/users/me/voice returns, in one request, the occupied voice channels of every room on this
node the user is a member of, limited to the channels they may view, and voice.state events keep it current. Rooms
on other nodes are asked on those nodes with the user’s session there. With several instances the request is relayed
to the leader, which holds the participants; with voice off it returns an empty list. Applications need the voice
scope for it.
Native voice in the desktop app
Section titled “Native voice in the desktop app”The desktop app (Linux and Windows) can use a native voice engine instead of the browser’s WebRTC for calls and
voice channels: on Linux it always does, on Windows it is an experimental setting that is off by default. It talks
to the same /rtc signaling and the same media ports as the web client, with the same end-to-end encrypted frames;
the only difference for the node is that it sends the LiveKit token in the Authorization: Bearer header instead of
the query string. tavrn checks the token either way and hands it to LiveKit as a bearer header. Proxies pass the
header by default; one that strips Authorization on WebSocket upgrades breaks desktop voice while browsers keep
working. The rest of the desktop app needs nothing on the node besides the CORS handling of
What the proxy must do and the /connect page of the web client
(Registration, accounts and sessions).
This page is generated from tavrn-node/docs/HANDBOOK.md (AGPL-3.0-only) when the website is built; its links were adapted for this site.