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:
- Each
→is a dependency you are on the hook for. Unset, the feature is gone and nothing else changes -- which is the convention above. Set, it is a URL that has to keep resolving, and the failure mode is that service's, not this one's. - For the four
VIBE_LIVE_OME_*_BACKEND_URLrows this changed on 2026-09-11, and the table below now says so per row. The image bundles OvenMediaEngine (O6), so the image sets those four to127.0.0.1and unset no longer arises: leave them and you get the copy in the box, set them and the bundled one stands down. Set means exactly what it always meant, which is why this landed without touching a deployment that already fills them in -- including this project's own cluster, which points them at its OvenMediaEngine Services and therefore never starts the bundled one. - TURN is bundled too, via OvenMediaEngine's own TURN-over-TCP rather
than a second daemon: unset now means "use the relay in the box", and
setting
VIBE_LIVE_TURN_URLSadvertises an external relay to the player as well. - Valkey is deliberately not bundled. A single process is better off without it -- the chat fan-out and the rate limits stay in-process, which is correct and one hop faster. It is for several replicas sharing one database, and those set the variable. Its row below still reads the old way because for this shape the old way is right.
| 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.
-
The shipped
VHost.xmlis the default selection,audio,720p, and a test fails the build if it stops being exactly that. -
For any other selection, render the file and mount it in place of the shipped one:
VIBE_LIVE_RENDITIONS=audio,360p,720p \ docker compose run --rm --entrypoint vibe-live vibe-live \ ome-vhost --template /opt/ovenmediaengine/bin/origin_conf/VHost.xml \ --out /dev/stdout > VHost.rendered.xml -
The bundled all-in-one image does this itself when it starts, from the same variable. Only a separate OvenMediaEngine container needs the step above, and the app logs a warning naming it whenever the selection is not the default.
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
- AdmissionWebhook pointed at
http://<vibe-live>:8081/internal/ome-admission, with<SecretKey>equal toVIBE_LIVE_OME_ADMISSION_SECRET. This is what makes stream keys mean anything; without it anyone can publish to any channel name. <HWAccels>left off unless you built OME yourself with CUDA support. The publishedairensoft/ovenmediaengineimage is compiled without it, and pinningModules="NV"with no NV device leaves OME with an empty encoder candidate list -- every frame fails to decode and no playable segment is ever produced. Transcoding is on the CPU here for that reason, including this repo's own cluster (O7).
Only for WebRTC -- sub-second playback, and publishing from the browser
- UDP 10000 reachable from the internet, and OME configured to advertise an ICE candidate address the viewer can actually reach. Behind NAT this is your public IP, not the container's -- an OME that advertises a private address produces a stream that negotiates, connects, and delivers no media, with nothing in any log to say why.
- Audio transcoded to Opus on the WebRTC output profile. RTMP ingest is always AAC and OME's WebRTC publisher silently drops AAC tracks, so a bypass-everything profile gives every WebRTC viewer silent video.
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.