vibe-live docs

Deploying vibe-live: all-in-one

Two containers and one command. compose.yaml at the repo root runs the app with its media server inside it, and a TLS terminator that gets its own certificate; the data lives in SQLite on a volume. Unless you need more than one backend replica, this is the shape to use.

./scripts/quickstart.sh          # asks for your hostname, writes .env
docker compose up -d
docker compose exec vibe-live vibe-live admin create <you> --admin

That starts from a checkout, and the repository is private until v1 -- compose.yaml, quickstart.sh and the Dockerfile all live in it. The registry does not have that restriction: the published image pulls anonymously, so docker run and the Proxmox appliance are the two routes that need nothing but the image. Everything below about variables, ports and probes applies to all of them.

That is the whole installation. What it produces:

Container What it is
caddy TLS on 80/443, and nothing else. Obtains and renews its own certificate, so there is no certbot step and nothing to forget.
vibe-live the API, the frontend, chat, the admission webhook -- and OvenMediaEngine, which has run inside this image rather than as a container of its own since 2026-09-11 (O6). s6 supervises the two processes; the app proxies /app/, /hls/, /cast-hls/ and /thumb/ to the media server over the loopback. It is configured by vibe-live-engine-ome/deploy/ome/, which is not a starting point but the real thing.

What this cannot do for you: DNS and five ports. Nothing in the stack can arrange either, and each failure is quiet in its own way.

Why Symptom when it is closed
the hostname resolves here it is the certificate's name and the app's own origin no certificate; caddy retries forever
80/tcp the ACME challenge, and the redirect to HTTPS no certificate, even though nothing is served on 80
443/tcp the site nothing loads
1936/tcp RTMP -- where OBS connects the settings page shows an address that refuses connections
10000/udp WebRTC media streams negotiate, connect, and deliver no video
3478/tcp WebRTC over TCP, for viewers and streamers whose network blocks UDP those viewers fall back to HLS; those streamers cannot go live from the browser at all

Behind a router, all five have to be forwarded to this host. The last one is the only one a working instance survives without, and it is on by default: the media server listens on it (OME_TCP_RELAY, empty meaning *:3478) and advertises the relay in its own SDP answer, so leaving it closed is a choice to serve those viewers HLS instead.

These five are the same for every engine. LiveKit and the built-in engine's WebRTC use 10000/udp and 3478/tcp for the same two jobs, and everything browser-facing that is HTTP comes through the app on 443, so changing engine never changes what the router forwards.

Doing it by hand instead. cp .env.example .env and fill it in; that file documents every variable this app has. The four secrets are generated, never chosen, and none may share a value with another:

openssl rand -base64 32   # VIBE_LIVE_DB_ENCRYPTION_KEY
openssl rand -hex 32      # VIBE_LIVE_TOKEN_HASH_PEPPER
openssl rand -hex 32      # VIBE_LIVE_COOKIE_SECRET
openssl rand -hex 32      # VIBE_LIVE_OME_ADMISSION_SECRET

Without openssl, head -c 32 /dev/urandom | base64 gives the first shape and head -c 32 /dev/urandom | xxd -p -c 64 the other three.

The process refuses to start with any required variable missing, and one run names all of them with the command that produces each -- so if you would rather not read the table below, start it and read what it says:

{"level":"ERROR","msg":"missing required environment variable",
 "key":"VIBE_LIVE_DB_ENCRYPTION_KEY",
 "hint":"a base64 key of exactly 32 bytes -- generate with: openssl rand -base64 32 ..."}

compose builds vibe-live from this repo's Dockerfile (default target), and does not have to: forgejo.gmatte.xyz/gmatte/vibe-live:latest is that same image, published on every release and pullable without credentials, so putting image: there instead of build: skips the build entirely. Either way it is built FROM airensoft/ovenmediaengine:0.18.0, which is where the media server comes from -- pinned rather than :latest, because OME's configuration schema is versioned and a newer image can refuse a document it does not expect. The only other image here is caddy's, caddy:2.10-alpine.

LiveKit instead of OvenMediaEngine? The all-in-one-livekit image and compose.livekit.yaml are the same idea with LiveKit, Ingress and Egress as the media half; docs/deployment/livekit.md covers what differs -- a key pair, and nothing about the ports.

No media server at all? The all-in-one-ffmpeg image is the app's own media path -- its RTMP listener, its ffmpeg, and optionally its own WebRTC server -- with nothing beside it, and the same five ports (three without WebRTC); docs/deployment/builtin.md covers it, including the one thing it gives up (LL-HLS).

CI publishes all of them, each a --target of this repo's Dockerfile: the image on this page under the unqualified tags, ffmpeg- and livekit- for the other two engines (since 2026-09-28), and the backend-/frontend- pair for the split shape -- each as <prefix><commit sha>, <prefix><version> and, on a release, <prefix>latest.

Putting this in an LXC on Proxmox? docs/deployment/proxmox-lxc.md covers two routes. The appliance runs this image as the container's own filesystem, with s6 as its init and no Docker inside -- no compose, no nesting, no keyctl, and no caddy either, so TLS goes in front. The other puts Docker in the LXC and runs this same compose.yaml there, which is where nesting and keyctl come in and fail in a way that names neither.

No compose at all

The published image is the whole stack bar TLS, so docker run is a deployment on its own -- the same thing the Proxmox appliance does with an LXC instead of a container:

docker run -d --name vibe-live \
  -e VIBE_LIVE_PUBLIC_BASE_URL=https://live.example.org \
  -e VIBE_LIVE_DB_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
  -e VIBE_LIVE_TOKEN_HASH_PEPPER="$(openssl rand -hex 32)" \
  -e VIBE_LIVE_COOKIE_SECRET="$(openssl rand -hex 32)" \
  -e VIBE_LIVE_OME_ADMISSION_SECRET="$(openssl rand -hex 32)" \
  -e VIBE_LIVE_PASSWORD_LOGIN=true \
  -p 8080:8080 -p 1936:1936 -p 3478:3478 -p 10000:10000/udp \
  -v vibe-live-data:/data \
  forgejo.gmatte.xyz/gmatte/vibe-live:latest

Then docker exec vibe-live vibe-live admin create <you> --admin, and put your own TLS terminator in front of 8080 -- that is the one thing compose's caddy service was doing for you. Keep the secrets somewhere: regenerating VIBE_LIVE_DB_ENCRYPTION_KEY makes every stored credential unreadable.

Verified 2026-09-24, running exactly that against :latest: s6 as PID 1 supervising svc-vibe-live and svc-ome, /livez and the page answering, /api/v1/me a signed-out 401, the engine inferred as ome, the admin command creating an account, and 1936, 3333, 3335, 3336, 3337, 3478, 8080, 8081 and 8091 all listening -- which is also where the claim that 3478 is on by default comes from.

Already have a reverse proxy? Delete the caddy service and point yours at vibe-live:8080. deploy/caddy/Caddyfile is four lines and says what it does; the only non-obvious one is flush_interval -1, without which LLHLS -- a chunked response that never ends -- is buffered into playing nothing.

Leave the security headers to the app. Every response already carries them (internal/server/securityheaders.go), including a Content-Security-Policy. What a browser refuses under it is reported to /api/v1/csp-reports and logged once per distinct violation, as a warning containing content security policy refused this -- the first place to look if something stops loading after a proxy or theme change. A proxy that adds its own policy, or strips these headers, makes those reports describe the wrong thing.

The one thing not to get wrong

The container listens on two ports:

Port What Exposure
8080 everything a browser touches behind your TLS terminator
8081 the OME admission webhook OME only, never the internet

The admission webhook is what decides whether a publish attempt is allowed. It is HMAC-signed, but it is separated onto its own listener precisely so that a routing mistake cannot expose it -- compose.yaml publishes 8080 and not 8081 for that reason, and the two are different http.ServeMuxes in the code so that no path on 8080 can reach it. Verified: a POST to /internal/ome-admission on port 8080 returns the frontend's index.html, not the webhook.

If you put a reverse proxy in front, give it 8080 only.

Probes

Four of them, and they answer different questions. internal/server/probes.go has the reasoning; this is what to point at what.

Where Answers What acts on it
GET /livez 8080 and 8081 the process is alive a liveness probe, which RESTARTS the container when it fails
GET /readyz 8080 and 8081 this replica can serve: boot finished and the database answers a readiness probe, which removes it from the load balancer
GET /startupz 8080 and 8081 boot finished; flips once and never back a startup probe, which holds the other two off while it fails
GET /healthz 8081 only the aggregate, as JSON: a line per check, whether each is required, and how long it took a human, or a monitor

/livez is unconditional and has to stay that way. Kubernetes restarts a container whose liveness probe fails, so anything checked there is something whose brief failure kills the process. Put the database in it and a ten-second database blip restarts every replica at once -- turning an outage that would have recovered into a crash loop that will not, while the restarts themselves add connection churn to the database that is already struggling. Readiness is where a dependency belongs, because failing it only stops traffic arriving.

The media engine is deliberately in none of them. A dead media server means nobody can go live; it does not mean this replica should leave the load balancer, since the dashboard, chat, sign-in and the whole API still work. /healthz reports it with "required": false for exactly that reason. Alert on it, do not gate traffic on it.

Compose already uses them: the healthcheck in each compose file is /readyz, which is why depends_on: service_healthy waits for a backend that can actually reach its database rather than one that has merely bound a port.

Point a monitor at /healthz on 8081, never on 8080. There is no public /healthz, and the public port ends in the SPA fallback -- so a monitor asking 8080 for it gets index.html with a 200, which reads as healthy forever. That is the shape of failure this repo keeps guarding against, and here the guard is knowing which port to ask. Verified 2026-09-24 against a running binary: 8081 answers the JSON, 8080 does not serve the path at all.

On Kubernetes:

startupProbe:                   # generous: this is the one that waits
  httpGet: { path: /startupz, port: 8081 }
  periodSeconds: 2
  failureThreshold: 60          # two minutes of boot before giving up
livenessProbe:
  httpGet: { path: /livez, port: 8081 }
  periodSeconds: 10
readinessProbe:
  httpGet: { path: /readyz, port: 8081 }
  periodSeconds: 5

Port 8081 rather than 8080 for all three: kubelet reaches the pod directly, so the probes need nothing from the Ingress, and using the internal listener keeps them working in a deployment that puts an authenticating proxy in front of the public port. Both ports serve the first three either way.

A gRPC probe

VIBE_LIVE_GRPC_HEALTH_ADDR=:8082 opens a cleartext HTTP/2 listener serving the standard grpc.health.v1.Health/Check, for a deployment whose probes speak gRPC:

readinessProbe:
  grpc: { port: 8082 }

It reports the same answer /readyz does -- one function, so the two cannot disagree. Unset, no listener opens, which is the default. grpc-health-probe -addr=host:8082 works against it; the implementation is internal/grpchealth, which speaks the protocol directly rather than importing gRPC, and its package comment says what that choice cost and how it was verified. Do not publish the port: like the internal listener, it needs no authentication precisely because it is not reachable from outside.

Configuration

Every variable below is read by cmd/vibe-live/main.go. .env.example is the copy to edit; this is what the values mean.

Required -- the process refuses to start without them

Variable Notes
VIBE_LIVE_PUBLIC_BASE_URL e.g. https://live.example.org. Every URL the app hands out is built from this, including the OIDC callback -- and caddy uses the same value as its site address, so the certificate is issued for exactly the name the app thinks it has. No trailing slash.
VIBE_LIVE_TOKEN_HASH_PEPPER Keys credential hashes. Changing it invalidates every bot token and app password.
VIBE_LIVE_DB_ENCRYPTION_KEY Must decode to exactly 32 bytes: openssl rand -base64 32. Encrypts credentials at rest. A key of the wrong length is refused at startup and says so.
VIBE_LIVE_COOKIE_SECRET Signs session cookies. Changing it signs everyone out.
VIBE_LIVE_OME_ADMISSION_SECRET Must equal OvenMediaEngine's <SecretKey>, which compose gives it as OME_ADMISSION_SECRET from this same variable. A mismatch rejects every publish.

And a way to sign in, which is a choice rather than a variable. Either the three VIBE_LIVE_OIDC_* variables are all set, or VIBE_LIVE_PASSWORD_LOGIN=true. Neither, and the process refuses to start -- which is deliberate, because the alternative is an instance nobody can sign in to that looks perfectly healthy. Half of OIDC set is also refused, for the same reason a wrong secret is better caught at boot.

Variable Notes
VIBE_LIVE_OIDC_ISSUER Must serve {issuer}/.well-known/openid-configuration.
VIBE_LIVE_OIDC_CLIENT_ID
VIBE_LIVE_OIDC_CLIENT_SECRET
VIBE_LIVE_PASSWORD_LOGIN true to allow username/password accounts. Alone, it is the whole sign-in story and needs no other software, which is why quickstart.sh chooses it. Alongside OIDC it means "also allow passwords".

Refusing to start is deliberate for all of these. A deployment that came up without VIBE_LIVE_DB_ENCRYPTION_KEY would write stream keys and Fediverse tokens to disk in the clear while looking healthy, which is worse than not starting.

Back up VIBE_LIVE_DB_ENCRYPTION_KEY with the database, not separately. Losing it makes every stored credential unreadable: users must re-link their Fediverse accounts and every stream key must be rotated. There is no recovery path, by design.

Optional -- and there are now two kinds

Since 2026-09-11 the image bundles OvenMediaEngine, so "optional" splits in two and the difference decides what happens when you leave a variable out.

Bundled: leaving it unset uses the copy in the image. Setting it points this instance at your own and the bundled one stands down, saying so in the log. Nothing is lost by ignoring these.

Variable Bundled component
VIBE_LIVE_OME_LLHLS_BACKEND_URL OvenMediaEngine: HLS playback
VIBE_LIVE_OME_CAST_HLS_BACKEND_URL OvenMediaEngine: Chromecast
VIBE_LIVE_OME_WHIP_BACKEND_URL OvenMediaEngine: browser publishing
VIBE_LIVE_OME_THUMBNAIL_BACKEND_URL OvenMediaEngine: dashboard previews
VIBE_LIVE_OME_API_URL / _TOKEN OvenMediaEngine's API: force-stop a stale session
VIBE_LIVE_TURN_URLS / _SECRET / _REALM The media server's own TURN-over-TCP relay

Feature switches: leaving one unset removes exactly that capability. This convention is deliberate -- an unset variable removes a capability cleanly rather than half-breaking the app -- and it is what the rest of this section describes.

A → marks a variable that points this instance at a service running somewhere else -- a database, a Valkey, or one of the bundled components above when you would rather run your own. Two things follow from the distinction, and they are the reason it is drawn here rather than left to be noticed:

Variable Unset means
→ VIBE_LIVE_MEDIA_HOST The RTMP address shown on the settings page falls back to a built-in default that is almost certainly not yours. Marked because it names your OME, but it is the only → that carries no traffic: it is printed for the streamer to paste into OBS, so a wrong value is a stream nobody can start rather than a route that breaks.
VIBE_LIVE_RTMP_PORT Unset, OBS is told port 1936. Set it only when your router forwards RTMP to this container from another port -- a second instance on the same public address, which cannot share 1936 because RTMP carries no hostname.
VIBE_LIVE_ADMIN_GROUP Defaults to vibe-live-admins. Membership comes from the OIDC groups claim.
VIBE_LIVE_OIDC_NAME / _ICON The sign-in button says "Sign in with your identity provider" rather than naming yours. Set the name (e.g. Authentik) and, if you like, an icon — a logo URL or an emoji.
→ VIBE_LIVE_OME_LLHLS_BACKEND_URL Bundled: the image points this at its own OvenMediaEngine, so HLS works out of the box. Set it to an external media server and the bundled one does not start.
→ VIBE_LIVE_OME_CAST_HLS_BACKEND_URL Bundled, as above. Chromecast works without configuration.
→ VIBE_LIVE_OME_WHIP_BACKEND_URL Bundled, as above. Publishing from the browser works without configuration.
→ VIBE_LIVE_OME_THUMBNAIL_BACKEND_URL Bundled, as above. Dashboard previews work without configuration.
→ VIBE_LIVE_OME_API_URL / _TOKEN Bundled: the image wires both to its own OvenMediaEngine, deriving the token so force-stop works with nothing configured. Against an external media server, a publish session started on a device that is now gone can only time out on its own (about a minute) unless you set both -- the app refuses to start with one. Needs <Managers><API> in that server's Server.xml.
VIBE_LIVE_OME_VHOST / _APP default and app, which is what a stock Server.xml declares.
VIBE_LIVE_FEDIVERSE_ENABLED No Fediverse linking or "went live" posts. No instance credentials are needed when it is on -- each user picks their own instance and the app registers an OAuth app with it on demand.
VIBE_LIVE_DOCS_ACCESS Who may read this documentation at /docs/ on your instance, until an admin chooses in Settings, Instance: off (only admins; the default), users (any signed-in account) or public.
VIBE_LIVE_CONTACT The privacy policy and terms (/privacy, /terms) name "the administrators of this instance" as the contact. Set an email address or a URL; Twitch and YouTube require a privacy policy people can act on.
VIBE_LIVE_FORWARD_LISTEN_ADDR / VIBE_LIVE_FORWARD_URL 127.0.0.1:1937 and rtmp://127.0.0.1:1937: the forwarder every engine pushes a platform's stream through, which vibe-live re-publishes over RTMPS. Right for all-in-one. Split, listen on the backend's address and give the engine its name; never publish the port.
VIBE_LIVE_TWITCH_CLIENT_ID / _SECRET Streamers cannot go live on Twitch from here. Set both to an application you registered with Twitch (multistreaming); one without the other refuses to start.
VIBE_LIVE_YOUTUBE_CLIENT_ID / _SECRET Streamers cannot go live on YouTube from here. Set both to a Google OAuth client with the YouTube Data API enabled (multistreaming).
VIBE_LIVE_YOUTUBE_PRIVACY Broadcasts made on YouTube are public. unlisted or private instead.
→ VIBE_LIVE_TURN_URLS / _SECRET / _REALM Bundled: OvenMediaEngine relays over TCP for both viewers and streamers whose network blocks UDP, and advertises itself, so nothing needs configuring. Set these to advertise an external relay to the player as well -- worth it if you already run one nearer your viewers.
VIBE_LIVE_TURN_TTL A built-in default. It only has to outlast starting a stream — the credential is minted per page load.
→ VIBE_LIVE_VALKEY_ADDR In-process chat fan-out and rate limits. Correct here -- there is only one process. Setting it is what the split shape needs, and it is the one → whose unset state is right rather than merely acceptable for this shape.
→ VIBE_LIVE_DB_DSN SQLite at /data/vibe-live.db. VIBE_LIVE_DB_PATH is the older SQLite-only name and still works.

OIDC provider setup

The provider needs {VIBE_LIVE_PUBLIC_BASE_URL}/auth/callback registered as a redirect URI, matched exactly. With Authentik specifically, the provider also needs its grant types and property mappings actually set -- an Authentik provider created with grant_types = [] and no property_mappings completes the redirect and then fails the token exchange with invalid_grant, which is a confusing way to spend an evening.

Signing in

Two ways, and the default is unchanged from what this app has always done.

With an identity provider (VIBE_LIVE_OIDC_* set): visiting the site sends you to it. Nothing else is needed and nothing else appears.

Without one, or with VIBE_LIVE_PASSWORD_LOGIN=true alongside it, usernames and passwords work too. Accounts are made from inside the container, never through a public signup form:

docker compose exec vibe-live vibe-live admin create alice --admin

vibe-live twice is not a typo: the service, then the binary inside it. It prompts for the password. To script it instead, feed it on stdin -- and note that the argument form does not exist for a reason:

kubectl exec -i deploy/vibe-live -- \
  printf 'a-long-passphrase' | vibe-live admin create alice --admin

The password comes from stdin because an argument lands in shell history and is visible in ps to everything on the host. --admin grants the only admin powers there are: see everyone, erase anyone (F20).

Federation peers have the same shell path, and for the same reason: the admin page needs a browser session, and an operator scripting a deployment has none.

docker compose exec vibe-live vibe-live peer list
docker compose exec vibe-live vibe-live peer set other.example --streams
docker compose exec vibe-live vibe-live peer list --json   # for a script

--streams is both directions at once; --show-streams alone puts their streams on this dashboard without offering ours, and --share-streams alone does the reverse. Chat is the same: --accept-chat lets their signed-in visitors write in your chat, --share-chat lets yours write in theirs, and --chat is both. Each is off unless given and needs the other instance's matching half -- they share for you to show or accept, and the reverse; sign-in from that host is open unless --block-signin is given, because signing in to watch needs no agreement between admins (F62). The command writes the whole row, exactly as the admin page does.

An account your identity provider stands behind cannot use a password -- not to sign in, not to set one, not to reset one. Otherwise "revoked in Authentik" would mean "revoked unless they remember their password". For scripted access to such an account, use an app password from the settings page: scoped, revocable, no admin rights.

Local accounts can add a second factor from their settings page: standard TOTP against any authenticator app, with ten single-use recovery codes shown once at enrolment. SSO accounts get their second factor from the provider, so the section does not appear for them.

Configuring OIDC without redeploying. If the VIBE_LIVE_OIDC_* variables are unset, an admin can set the issuer, client ID and secret from Settings → Instance, and they take effect at the next restart. When the environment supplies them, that section renders read-only and refuses writes -- the environment is authoritative, and a write that silently did nothing would be worse than a refusal. A provider configured through the UI that cannot be reached is skipped at startup with a loud log rather than preventing boot, so a mistyped issuer does not lock you out: sign in with a password and fix it.

Password reset and address verification need SMTP (VIBE_LIVE_SMTP_ADDR). Without it the instance starts and says so at startup -- there is simply no reset, which matters most for the first admin account, so create that one with a password you have written down.

Notifications

Followers are told when a stream starts, and mentions in chat reach the person mentioned. Both go over Web Push, which needs one key pair:

vibe-live vapid            # prints VIBE_LIVE_VAPID_PRIVATE_KEY=...

Put it in .env and restart. Optionally set VIBE_LIVE_VAPID_SUBJECT to a mailto: or https: URL for a push service to contact about this sender; it defaults to this instance's own origin.

Do not replace the key once people have subscribed. Every subscription a browser creates is bound to the public key derived from it, so a new key does not invalidate them with an error -- they simply stop being deliverable, and the first sign is that notifications stopped.

Without a key the instance runs exactly as before and never offers to notify anyone. That is a supported state, not a degraded one: nothing prompts, and the follow button on a signed-out browser is not drawn at all.

Two things worth knowing before you promise anyone notifications. On iPhone and iPad, Safari delivers Web Push only to a site added to the home screen -- in the browser it is not available at all, and the UI says so rather than offering a control that cannot work. And a person who refuses the permission prompt cannot be asked again by the site; only their browser settings can undo it.

Federation

This instance speaks ActivityPub. Nothing has to be configured for it: every account is followable from Mastodon or anything like it as @name@<the host in VIBE_LIVE_PUBLIC_BASE_URL>, and a stream starting is announced to whoever followed. Streamers can turn that off per channel under Settings.

Handles on a different domain -- served from live.example.org, followed as @name@example.org -- are what VIBE_LIVE_ACCOUNT_DOMAIN is for. It is Mastodon's LOCAL_DOMAIN against WEB_DOMAIN, and GoToSocial's account-domain.

Setting it is not enough on its own, and this is the part that bites. ActivityPub has no delegation file the way Matrix's /.well-known/matrix/server is one: the domain in the handle has to genuinely answer WebFinger. So example.org must serve

/.well-known/webfinger?resource=acct:<name>@example.org

by proxying or redirecting it to this instance -- a 302 is fine, WebFinger clients follow redirects. Set the variable without arranging that and you publish a handle nothing can resolve, which is worse than the longer one it replaced.

A handle has exactly one owner. If another fediverse server already answers for a name on that domain, it cannot also be this one; you would have to move it, or use a different name. Different names on one domain are fine -- the proxy rule can branch on the resource parameter.

The actor URLs stay on VIBE_LIVE_PUBLIC_BASE_URL either way. An actor id appears in every activity that account ever signs, so it must not move once another server has seen it -- which is also why this variable is worth deciding before anyone follows you, and cheap to leave unset until then.

Data

The /data volume is the deployment. It holds the SQLite database: accounts, channels, chat history, stream keys, linked accounts. Back it up together with VIBE_LIVE_DB_ENCRYPTION_KEY.

Past broadcasts -- streams kept after they end, when an admin allows it and each streamer opts in (both off by default) -- are written beside the database, in /data/recordings, so backing up /data backs them up too. VIBE_LIVE_RECORDINGS_DIR puts them elsewhere, a bigger disk for instance. Size it at about the streamers' bitrate times what you keep: a 2-hour stream at 6 Mbit/s is about 5.4 GB. The admin page can cap it -- how full the disk may get, or a size, and per streamer -- and shows what it takes; see the instance settings. Past broadcasts can also live in an S3-compatible bucket instead of on this disk -- the instance's own, or each streamer's -- set up from the admin page, not the environment; see the instance settings. A replay in a bucket is uploaded from here when its stream ends, and the outbound traffic is this server's.

Every live stream is recorded there too while it runs, so viewers can seek back through it from its start (on unless an admin turns it off), and deleted when it ends unless it is kept: budget the streamers' bitrate times their longest streams, or cap it with VIBE_LIVE_DVR_WINDOW (1m to 24h).

Where /data lives. compose.yaml keeps it in a named volume, vibe-live-data. To keep it in a directory on the host instead -- to see it, back it up with your other files, or move it to a bigger disk -- set VIBE_LIVE_DATA_HOST_DIR in .env to that directory, and give it to the image's user before the first start:

sudo mkdir -p /srv/vibe-live/data && sudo chown -R 10001:10001 /srv/vibe-live/data

The image runs as uid 10001, not root (since 0.16.0): a directory it cannot write makes it refuse to start, saying which one and what to run.

Upgrading to 0.16.0: the image no longer runs as root

Until 0.15.0 everything in the image ran as root, so /data -- the volume or host directory -- is root's. From 0.16.0 the app runs as uid 10001 and refuses to start on data it cannot write ("refusing to start on data this process cannot write", with the command to run). Hand the data over once, with the instance stopped:

# a named volume (compose.yaml's default):
docker run --rm -v vibe-live-data:/data --entrypoint chown \
  forgejo.gmatte.xyz/gmatte/vibe-live:0.16.0 -R 10001:10001 /data
# a host directory:
sudo chown -R 10001:10001 /srv/vibe-live/data

On Kubernetes, fsGroup: 10001 in the pod's securityContext does it for volume types that support ownership; a local-path or hostPath volume does not, and needs the chown on the node (or an init container that runs it as root before the app starts). Proven on a copy of an older volume: refused before the chown, healthy after it.

Schema migrations run at startup and are versioned -- the binary refuses to open a database migrated by a newer build rather than starting and failing later on a column it does not know about. SELECT * FROM schema_migrations shows what ran, how long it took, and what failed if anything did.

OvenMediaEngine

compose.yaml runs it, from inside the image, and Server.xml and VHost.xml configure it. They are built into the image's OvenMediaEngine tier (/opt/ovenmediaengine/bin/origin_conf/), and their source is deploy/ome/ in vibe-live-engine-ome, OvenMediaEngine's own repository since F68. Those two files are not a starting point: they are the cluster's own configuration with the Kubernetes-specific parts removed, and every comment in them is a live outage somebody already paid for. Read one before changing it.

Renditions come from VIBE_LIVE_RENDITIONS, and OvenMediaEngine reads them from VHost.xml.

Seeking through a live stream needs nothing from OvenMediaEngine: the app records the stream from OvenMediaEngine's plain HLS (the output Cast plays, so VIBE_LIVE_OME_CAST_HLS_BACKEND_URL must be set -- the bundled image sets it) and serves the seek playlist itself. vibe-live ome-vhost renders no DVR block of OvenMediaEngine's own any more.

OvenMediaEngine cannot add a rendition to a running stream, so unlike the built-in engine and LiveKit, it makes every selected rendition for every live stream, all the time, even when nobody watches it and even when the source is shorter. Each one is an encode; keep the selection small here.

The image redistributes OvenMediaEngine, and has since it became the image's base (O6): AGPL-3.0, unmodified, pinned at the version these files were written against. Its licence and a written offer of its exact source (vibe-live-engine-ome/licenses/ovenmediaengine-SOURCE-OFFER.txt) ship inside the image, which is what redistributing it obliges.

What you still have to arrange yourself: DNS and the five ports at the top of this document. Nothing in a compose file can open a router.

Four things have to be right in an OME configuration, which is what those files get right. Which of them apply depends on whether you want the WebRTC path -- an RTMP-in, LLHLS-out deployment needs no UDP, no ICE candidate address and no Opus, and that is exactly the shape the federation e2e harness runs inside a container network (docs/testing/federation-e2e.md). They are listed here because a self-hoster behind NAT will meet all four, and because knowing what the config is doing is the difference between fixing it and rewriting it.

Always

Only for WebRTC -- sub-second playback, and publishing from the browser

Skip both and you still have a working instance: the player falls back to LLHLS on its own (seconds rather than sub-second, which is what player-source.ts already handles for viewers on networks that block WebRTC), and streamers publish over RTMP from OBS instead of from the browser. It is a smaller product, though -- sub-second latency is the reason this project exists rather than owncast -- so it is a fallback position, not the target.

All four were real outages in this repo's own deployment. The shipped configuration answers all four; what it cannot answer is a router.

Changing any of it means restarting OME, not just saving the file: OME reads its configuration once, at startup, and the files are mounted read-only for that reason. It shares a container with the app, so that is docker compose restart vibe-live -- there is no ome service to restart in this stack, and compose.scalable.yaml is the one where there is.

On Kubernetes, WebRTC is the part that cannot be bundled. UDP 10000 on the host is a pod-level property (hostNetwork), not something an env var can turn on, so a Kubernetes deployment that wants sub-second playback is always vibe-live plus a separate OME with its own manifests -- whatever a future single-image distributable does for docker run self-hosters. This repo's own cluster deployment is exactly that shape.

Validating the install

scripts/validate-setup.sh https://your.instance

One command, needing only curl, that checks each piece against your own configuration: the app answers, the certificate validates, the page loads, link previews are served, all four media routes reach a media server, the signalling upgrade works, and the admission webhook is NOT exposed.

It exists because almost every mistake here leaves a site that loads. The app starts without a media server, the dashboard renders, sign-in works -- and going live is what breaks, usually with an audience. Each failure names the variable or the route to look at.

Checking it works

On the host, against the app directly -- compose binds 8080 to loopback for exactly this:

curl -s localhost:8080/livez                 # 200
curl -s localhost:8080/api/v1/instance       # instance name/description
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/settings   # 200, SPA
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/v1/me  # 401 signed out

The last one returning 401 rather than 500 is the useful signal: the API is up and authentication is wired.

Then the parts that involve caddy and the media server, in the order they fail in:

curl -sI https://<your host>/livez           # 200, and a valid certificate
docker compose logs caddy | grep -i certificate
docker compose logs vibe-live | grep -i 'ice\|candidate'

A certificate that never issues is DNS or port 80, every time. An ICE candidate that reads as a private address means STUN did not answer and OME fell back to enumerating its own interfaces -- set OME_ICE_CANDIDATE to your public address and restart it.

The one thing no curl can tell you is whether video works, because that needs a publisher. Point OBS at the address the settings page shows you and watch your own channel; if the page plays, all four ports are right.