Skip to main content

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.

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 on 127.0.0.1:7880 inside the container only. Do not publish or proxy port 7880, and do not route /rtc anywhere but to tavrn. Browsers send the token as the access_token query parameter; the desktop app’s native voice engine sends it as Authorization: Bearer, so the proxy must pass that header through on the /rtc upgrade (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:

    WhatPort(s)Setting
    MediaUDP 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_PORT with TAVRN_RTC_TURN_ENABLED=true
    TURN over TLS (optional)TCP 443 on the proxy → 127.0.0.1:5349TAVRN_RTC_TURN_TLS_PORT, through the proxy

    docker-compose.yml publishes 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.

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 commented networks: block in docker-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 to TAVRN_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.

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 to turns:<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_RANGE inside 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 in TAVRN_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 with TAVRN_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, /healthz shows voice_problem and LiveKit falls back to the STUN detection.
  • TURN with a hostname: TAVRN_RTC_TURN_ENABLED=true with TURN over TLS, clients use turns:turn.<host>:443 and resolve that name themselves at every reconnect, so a new address takes effect at the next reconnect without the node noticing. Give turn.<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.

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 defaults TAVRN_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; lower TAVRN_VOICE_MAX_BITRATE_KBPS to 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 the stream permission, 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.

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.

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.

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.

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.