Skip to content

Runbook: Element Call (MatrixRTC via LiveKit)

Group/voice video calling for the Matrix stack, per continuwuity's guide (https://continuwuity.org/calls/livekit). Stack: stacks/livekit. Fronted at lk.fmm.house (wildcard DNS, no new record). Two containers:

Container Image Role
livekit livekit/livekit-server:v1.13.3 Media SFU (WebRTC) — host-networked
lk-jwt-service ghcr.io/element-hq/lk-jwt-service:0.5.0 Issues LiveKit tokens to Matrix users (:8081, proxy net)

Traefik routing (lk.fmm.house)

Path-based across two backends: - /sfu/get, /get_token, /healthzlk-jwt-service (a priority-100 label router on the container, stacks/livekit/compose.yml). - everything else (incl. the wss:// signaling) → livekit SFU (stacks/traefik/dynamic/livekit.yaml172.30.0.1:2006, since a host-networked container has no proxy-net IP).

Ports (see docs/host-port-map.md)

  • 2006/tcp — HTTP/WS signaling. Internal only (Traefik reaches it via the bridge); stays closed externally.
  • 2007/tcp + 50100–50200/udp — WebRTC media, client-facing. Host networking means these are subject to ufw (they don't get Docker's publish-bypass), so both must be allowed:
    sudo ufw allow in on tailscale0 to any port 2007 proto tcp
    sudo ufw allow in on tailscale0 to any port 50100:50200 proto udp
    
    (Tailnet media to av's Tailscale IP is decrypted onto tailscale0, so scoping there is correct.) :2007 is the TCP media fallback — redundant on the tailnet (Tailscale's DERP already provides TCP-relay resilience) but kept as belt-and-suspenders / for any off-tailnet use.

Tailnet media (the tricky part)

Everything is tailnet-only, so LiveKit must advertise a Tailscale address for media, not the LAN or a public IP. livekit.yaml uses rtc.use_external_ip: false + rtc.ips.includes restricted to Tailscale's standard ranges (100.64.0.0/10 v4, fd7a:115c:a1e0::/48 v6) — so it advertises av's Tailscale addresses (both stacks), with no hardcoded host IP (portable).

⚠️ The startup log still prints nodeIP: <LAN IP> (that's the node identity, not the media candidate). LiveKit's IP filtering has known quirks, so confirm with a real call. If media won't connect, fall back to a fixed rtc.node_ip: <av tailscale IP>, or add rtc.ips.excludes for the LAN/bridge ranges.

Homeserver config (continuwuity)

stacks/matrix/compose.yml (env vars): - CONTINUWUITY_MATRIX_RTC__FOCI → the [global.matrix_rtc] foci list pointing at https://lk.fmm.house. - CONTINUWUITY_ALLOW_FEDERATION: "true" + CONTINUWUITY_FORBIDDEN_REMOTE_SERVER_NAMES: '[".*"]' — federation is enabled but forbids every remote server, so the server stays functionally an island. It's required only so lk-jwt-service can validate a caller's OpenID token via the federation openid/userinfo endpoint. - CONTINUWUITY_WELL_KNOWN__CLIENT: https://matrix.fmm.housegotcha #1. Element reads the RTC foci from /.well-known/matrix/client (org.matrix.msc4143.rtc_foci), NOT the /rtc/transports endpoint. continuwuity doesn't serve the client well-known unless this is set — without it Element shows MISSING_MATRIX_RTC_TRANSPORT even though /rtc/transports returns the transport fine. - CONTINUWUITY_WELL_KNOWN__SERVER: matrix.fmm.house:443gotcha #2. lk-jwt-service validates each caller's OpenID token via the homeserver's federation openid/userinfo endpoint, and discovers where that lives via /.well-known/matrix/server. Without this it defaults to the standard federation port 8448 (not open here) → times out → Element shows OPEN_ID_ERROR ("Failed to look up user info"). Delegating to :443 routes it through Traefik → continuwuity.

Verify the homeserver side: - curl -s https://matrix.fmm.house/.well-known/matrix/client → includes org.matrix.msc4143.rtc_foci. - curl -s https://matrix.fmm.house/.well-known/matrix/server{"m.server":"matrix.fmm.house:443"}. - .../\_matrix/federation/v1/openid/userinfo?access_token=x401 (reachable), not a timeout. - Full flow (login → openid/request_token → POST lk.fmm.house/sfu/get) returns a jwt — see scratchpad/validate_call.py for a scriptable check. - Element caches the well-known — hard-refresh / clear-cache after changing these.

Secrets / ops

  • LiveKit API key/secret in stacks/livekit/.env (gitignored, backup scope) — shared by both containers; regenerate with docker run --rm livekit/livekit-server:latest generate-keys.
  • No healthchecks yet (verify a probe in-container first, per ADR-0004). docker logs livekit / lk-jwt-service. Recreate: docker compose -f stacks/livekit/compose.yml up -d.