Setup
Voice setup (LiveKit)
How the bundled LiveKit media server is wired into core — ports, defaults, disabling voice and pointing at an external LiveKit.
Voice channels run on LiveKit, which ships as a service
in the core compose stack — nothing extra to install. Signaling (a
WebSocket) rides the API origin at /rtc, proxied straight to the LiveKit
container by Caddy, so it needs no domain or certificate of its own; the
media itself is plain WebRTC and flows directly between each browser and the
core host, never through the proxy.
Default setup
A fresh install works out of the box — nothing LiveKit-specific to configure. Three env vars matter, and all have working defaults:
LIVEKIT_API_KEY/LIVEKIT_API_SECRET— auto-generated into the shared secrets volume on first boot bysecrets-init, the same as the database password and the Meilisearch master key. Leave them empty unless you’re pointing at an external LiveKit.LIVEKIT_PUBLIC_URL— the URL browsers connect to. Left empty, it derives fromSERVER_DOMAIN(the API origin), which is correct for the bundled setup since signaling rides/rtcon that same origin.LIVEKIT_INTERNAL_URL— how theapp/workercontainers reach LiveKit’s admin API inside the Docker network. Defaults tohttp://livekit:7880; only change it if you rename the service.
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
LIVEKIT_PUBLIC_URL=
LIVEKIT_INTERNAL_URL=http://livekit:7880
Ports & firewall
| Port | Protocol | Used for |
|---|---|---|
| 7881 | TCP | Voice media, TCP fallback |
| 50000–50100 | UDP | Voice media (WebRTC/RTP) |
These are the only voice-specific ports — the signal WebSocket shares 443/tcp with the rest of the API. They must be reachable directly on the core host, not through a reverse proxy: no L7 proxy can carry WebRTC media. See Network configuration for firewall commands, the full port table and NAT/port-forwarding details.
The server discovers its own public IP (STUN) and advertises it to
browsers, which then connect straight to public-ip:50000–50100. Two
consequences worth knowing up front: behind CGNAT, no port forwarding will
help (see the CGNAT note on that page), and on a VPN-only deployment voice
still works fine — media just flows over the VPN instead.
Disabling voice
Can’t open the media ports, or don’t want voice at all? Set
VOICE_ENABLED=false in .env:
VOICE_ENABLED=false
This hides voice channels and every related control in the client, and the
server rejects voice operations (joining, creating an audio channel) even
if called directly. The bundled livekit container can also be dropped
from the stack entirely — it’s inert with voice disabled.
External or managed LiveKit
Running LiveKit elsewhere — LiveKit Cloud, or a separate self-hosted instance shared across multiple core deployments — is a config-only swap:
- Point the keys at that server
Set
LIVEKIT_API_KEY/LIVEKIT_API_SECRETin.envto the key/secret pair issued by that LiveKit instance. A non-empty value here always overrides the auto-generated one. - Set the public URL
Set
LIVEKIT_PUBLIC_URLto that server’s own signal URL (e.g.wss://your-project.livekit.cloud) — it has its own TLS, so the client connects to it directly and Caddy’s/rtcroute simply goes unused. - Configure the webhook
In the external provider’s dashboard, add a webhook pointing at
https://<SERVER_DOMAIN>/api/livekit/webhook, signed with the same key/secret — this is how join/leave events reach core.
With this in place the bundled livekit container and its published ports
(7881/tcp, 50000–50100/udp) are no longer needed on this host — that
capacity now lives wherever the external service is hosted.
Behind a reverse proxy
Only the voice signal goes through a reverse proxy — and like any WebSocket, it needs the proxy to forward the Upgrade/Connection headers on the API domain, not just plain HTTP. A host-level Caddy handles this by default; nginx and others need the explicit WebSocket block. See Behind a reverse proxy for the exact config.
The media ports are unaffected either way — they’re never proxied, bundled or external.