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 by secrets-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 from SERVER_DOMAIN (the API origin), which is correct for the bundled setup since signaling rides /rtc on that same origin.
  • LIVEKIT_INTERNAL_URL — how the app/worker containers reach LiveKit’s admin API inside the Docker network. Defaults to http://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:

  1. Point the keys at that server

    Set LIVEKIT_API_KEY / LIVEKIT_API_SECRET in .env to the key/secret pair issued by that LiveKit instance. A non-empty value here always overrides the auto-generated one.

  2. Set the public URL

    Set LIVEKIT_PUBLIC_URL to 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 /rtc route simply goes unused.

  3. 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

Warning

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.