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, /healthz → lk-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.yaml
→ 172.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:
(Tailnet media to av's Tailscale IP is decrypted onto
sudo ufw allow in on tailscale0 to any port 2007 proto tcp sudo ufw allow in on tailscale0 to any port 50100:50200 proto udptailscale0, so scoping there is correct.):2007is 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 fixedrtc.node_ip: <av tailscale IP>, or addrtc.ips.excludesfor 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.house — gotcha #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:443 — gotcha #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=x → 401 (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 withdocker 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.