Deploying vibe-live: LiveKit as the engine
LiveKit instead of OvenMediaEngine, in the same two shapes the other engines have (F59). Everything in all-in-one.md about required variables, sign-in, data and validating the install applies here too and is not repeated. What differs is the media half.
| What it runs | When to use it | |
|---|---|---|
all-in-one-livekit image |
the app, livekit-server, Ingress, Egress and Redis, supervised by s6 in one container | one host, one thing to run -- an LXC, a single VM, a homelab |
compose.livekit.yaml |
the same pieces as separate containers from LiveKit's official images, plus caddy and valkey | when you want to restart, pin or resource-limit each piece on its own |
The image is published by CI as livekit-<version> (and
livekit-<commit sha>, livekit-latest) since 2026-09-28, or built with
docker build --target all-in-one-livekit -t vibe-live:livekit ..
compose.livekit.yaml builds its own app container from the
all-in-one-ffmpeg target -- no OvenMediaEngine in this stack, and ffmpeg
is part of the app -- which CI also publishes, as ffmpeg-.
Both were proved on 2026-09-14 by publishing to them with a real RTMP encoder: the channel went live in about two seconds, and the thumbnail and the 720p and audio renditions were served. Both give the same features as the OvenMediaEngine shape: RTMP from OBS with the channel's stream key, Go Live from the browser (a LiveKit room token, not WHIP), sub-second WebRTC for viewers, the stream menu with the owner's renditions and Audio only, Cast, thumbnails on the cards, viewer counts, and force-stop.
How the pieces fit
- Encoders publish to the app's own RTMP port, 1936, with the stream key the settings page shows. The app authorises it, creates a LiveKit ingress for that one publish, and relays the stream to Ingress on a private port. The stream key never reaches LiveKit, and Ingress's port is never public.
- Browsers join LiveKit's signalling through the app, with a token the
app mints. The app proxies
/rtcon the site's own origin to LiveKit, the way it proxies OvenMediaEngine's/app/, so LiveKit has no hostname, certificate or public signalling port, andVIBE_LIVE_LIVEKIT_URLcan be left unset in both shapes. LiveKit's room API shares that port and is not proxied; the app calls it on the internal network. - Egress writes files into a media directory the app serves: each live channel's thumbnail for as long as it is live, and a rendition only while somebody is watching it. The directory is volatile (tmpfs) and must be the same path for Egress and the app -- one filesystem in the image, a shared volume in compose.
- Redis (valkey in compose) is LiveKit's own bus. livekit-server, Ingress and Egress find each other through it. It carries in-flight RPC only and keeps nothing.
Ports
| Why | Symptom when it is closed | |
|---|---|---|
| 80/tcp, 443/tcp | the site, and the ACME challenge (compose) | as in all-in-one.md |
| 1936/tcp | RTMP from encoders | the settings page shows an address that refuses connections |
| 10000/udp | WebRTC media | streams connect and deliver no video |
| 3478/tcp | WebRTC over TCP, for networks that block UDP | those viewers fall back to HLS; those streamers cannot go live from the browser |
The same five ports as the OvenMediaEngine shape, with the same jobs.
LiveKit muxes every peer connection onto its one udp_port rather than a
port range, and runs ICE over TCP on 3478 where OvenMediaEngine runs its TCP
relay; neither is HTTP, so neither can come through the app. LiveKit's own
7880 (signalling and room API) is never published: browsers reach /rtc
through the app. Until 2026-09-27 this shape needed 7881/tcp and the
50000-50100/udp range instead, and a route for /rtc in your proxy.
The app also listens on 1937/tcp, never published: the forwarder
the engine pushes a multistream (Twitch, YouTube) through, re-published
by the app over RTMPS (VIBE_LIVE_FORWARD_LISTEN_ADDR,
VIBE_LIVE_FORWARD_URL, loopback by default). On LiveKit, Egress makes the push.
Configuration
This is required in addition to all-in-one.md's list:
livekit-server generate-keys # VIBE_LIVE_LIVEKIT_API_KEY and ..._SECRET
| Variable | Notes |
|---|---|
VIBE_LIVE_LIVEKIT_URL |
What browsers join. Leave it unset: they then join on the site's own origin, through the app's /rtc proxy. Set it only for a LiveKit browsers reach elsewhere, and then /rtc there is yours to route. |
VIBE_LIVE_LIVEKIT_API_KEY, VIBE_LIVE_LIVEKIT_API_SECRET |
One pair, read by the app and every LiveKit piece. The secret must be at least 32 bytes -- the app refuses to sign with less, and every piece refuses to start without the pair. |
Everything else has a default that is the working configuration, in both shapes: the relay to Ingress, Egress on, the media directory, and the app's own address for LiveKit's API. Each can still be overridden:
VIBE_LIVE_LIVEKIT_EGRESS=falseturns thumbnails and HLS off, and the image's Egress stands down with it. Viewers keep WebRTC.VIBE_LIVE_LIVEKIT_INGRESS_RTMP_URLpointed at another Ingress makes the image's own stand down.VIBE_LIVE_RENDITIONSis the owner's selection, as on every engine.LIVEKIT_NODE_IP(image only) skips LiveKit's STUN lookup of its public address, the wayOME_ICE_CANDIDATEdoes for OvenMediaEngine.
VIBE_LIVE_OME_* do nothing here and the compose file does not pass them.
Seeking through a live stream (on unless an admin turns it off) works
with Egress on (VIBE_LIVE_LIVEKIT_EGRESS=true): each live channel then
runs one more Egress, recording its source into
VIBE_LIVE_MEDIA_DIR/recordings for as long as it is live. Egress cannot
copy a stream, so that is one encode per live channel for as long as it
is live, at the largest quality in VIBE_LIVE_RENDITIONS -- budget it
like one more rendition that is always watched, and size the directory at
that quality's bitrate times the streams' length (2.5 Mbit/s for an hour
is about 1.1 GB per channel), or cap it with VIBE_LIVE_DVR_WINDOW.
Without Egress there is nothing to seek through, the app says so at
start, and viewers are not offered it.
Past broadcasts use the same recording Egress, so they need Egress too.
On LiveKit they are written to VIBE_LIVE_MEDIA_DIR/recordings unless
VIBE_LIVE_RECORDINGS_DIR says otherwise -- it has to be a path Egress
writes at as well, which is why the default is inside the media directory
rather than beside the database. In compose that directory is a volume;
keep it to keep them.
Host directories instead of volumes. VIBE_LIVE_DATA_HOST_DIR (the
app's /data, its database) and VIBE_LIVE_MEDIA_HOST_DIR (the media
directory the app and Egress share) in .env put either in a directory on
the host rather than a named volume. The media one must be writable by
Egress's user, uid 1001: sudo chown -R 1001:1001 <dir>.
What is inside the image, and what is not
- It is LiveKit Egress's own image,
livekit/egress:v1.14.1(Ubuntu 24.04), because Egress's GStreamer pipeline is hundreds of libraries LiveKit builds and tests together. Ingress's binary runs on the same base. - Chrome is removed. Only room-composite and web Egress need a browser, and the app starts neither: thumbnails are image-only participant Egress, and renditions are participant and track-composite Egress. The image is one flattened layer, because deleting a package above the base leaves its bytes in the base's layers: 2,050 MB, against 3,221 MB for Egress's own image (measured 2026-09-14).
- Notices.
/usr/share/licenses/carries LiveKit's, ffmpeg's and x264's, and/usr/share/licenses/gstreamer/SOURCE-OFFER.txtnames every LGPL and GPL component in the base: LiveKit's GStreamer 1.24.12 plugin build and the Ubuntu packages beside it, each of which keeps its own copyright under/usr/share/doc.
Egress and CPU
Egress admits a request only while it estimates it has the CPU for it, so
on a small host a rendition somebody asks for can be refused while others
run. That is Egress's own admission, not the app's cap
(VIBE_LIVE_MAX_TRANSCODES is the built-in engine's). The app retries a
refused rendition on the viewer's next request and counts it in
vibe_live_rendition_start_failures_total. Choosing fewer renditions in
VIBE_LIVE_RENDITIONS is the lever.