vibe-live docs

Deploying vibe-live: the split shape

The backend and the frontend as separate containers, with MariaDB, Valkey, and an nginx edge in front doing the path routing that the all-in-one image does internally.

cp .env.example .env      # fill in, including the MariaDB block
docker compose -f compose.scalable.yaml up -d
docker compose -f compose.scalable.yaml up -d --scale backend=3

Use this only if you need more than one backend replica. It is strictly more moving parts than all-in-one, and a single-replica deployment gains nothing from it. Everything in that document about required variables, the admission webhook, data, and OvenMediaEngine applies here too and is not repeated.

Recording -- seeking through a live stream, and past broadcasts -- works in this shape, once per stream. Every replica hears a stream go live; the one that takes the channel's recording claim (a row in the database, renewed every 10 seconds) records it, and the others serve what it writes. If that replica dies, its claim lapses after 30 seconds and another takes the stream over: the recording has a gap there, like a stream that dropped. So every replica must mount one shared recordings directory: compose.scalable.yaml gives the backend a named volume at /recordings (VIBE_LIVE_RECORDINGS_DIR), which every replica on one host shares; replicas on several hosts need a volume all of them mount (NFS, or a ReadWriteMany volume on Kubernetes). VIBE_LIVE_RECORDINGS_HOST_DIR in .env puts it in a host directory instead of the named volume. A stream that drops and comes back within the stitch window continues its broadcast only when it comes back to the same replica; elsewhere it starts a new one.

What is the same, and the two things that are not

OvenMediaEngine is in this file too, as its engine tier (the same image whose built-in deploy/ome/ configuration the all-in-one runs), on the same ports, with the same secrets. One of it, not one per replica -- a media server is not something you scale by copying, and replicas share it exactly as they share MariaDB. So the five ports at the top of all-in-one.md apply here unchanged -- plus the backend's 1937/tcp on the stack's network only, where OvenMediaEngine pushes a multistream to the forwarder (VIBE_LIVE_FORWARD_URL=rtmp://backend:1937 in the file) -- and so does everything in that document's OvenMediaEngine section -- including rendering VHost.xml with vibe-live ome-vhost when VIBE_LIVE_RENDITIONS is not the default: this file runs the tier with its built-in configuration, so a rendered one is mounted over /opt/ovenmediaengine/bin/origin_conf/VHost.xml in the ome service.

Two deliberate differences:

What has been run, and what has not

Written against a stack that was actually brought up, on 2026-08-28 -- the containers, the images and the topology below are real, and three of the properties this shape exists for were checked rather than assumed:

Not verified: the compose files themselves. The bring-up used podman run with the same images, network and environment, because no compose provider was installed on the machine that did it. The compose files are a faithful transcription of that topology, not a transcript of a docker compose up. Treat the service definitions as reviewed and the wiring as tested.

Also not verified here: OvenMediaEngine, publishing, and playback. The ome service and its configuration were added on 2026-09-08 and have been started (OME 0.18.0 accepts the files and binds every port the app addresses), but nothing has published a stream through this topology -- that needs a real network and an encoder. What HAS been checked mechanically is that the two files and the XML agree about ports, secrets and the admission hostname; see cmd/vibe-live/ome_config_test.go.

Why an edge container exists

In the all-in-one shape one process serves both halves and decides internally which is which. Split in two, something in front has to know that /api/ belongs to the backend and /settings belongs to the frontend.

Getting that list wrong is silent, which is the reason this is its own document and its own file. A path that should reach the backend but lands on the frontend gets index.html with a 200 -- so the browser reports a JSON parse error, and the cause is nowhere near the symptom.

deploy/edge/nginx.conf holds the list. As of 2026-09-28 it routes to the backend:

/api/  /auth/  /avatars/  /fediverse/  /multistream/  /docs  /instance/icon  /ap/  /ws/
/livez  /readyz  /startupz
/app/  /hls/  /cast-hls/  /thumb/  /rtc
/c/ (the watch page's document only, for link previews)
= /manifest.webmanifest   = /.well-known/webfinger

and everything else to the frontend. That is the backend's own top-level routes plus the engine's media and signalling prefixes, and it is the same split as web/vite.config.ts's dev proxy. The file, not this list, is the authority: internal/server/routing_test.go holds it and the dev proxy to the routes the backend registers. Adding a new top-level route means touching both -- otherwise it works in the all-in-one shape and 404s here, which is exactly the kind of difference that survives review.

Two details in that file are load-bearing:

The edge is not a TLS terminator. Put certificates on it, or in front of it.

Restart the edge after scaling

nginx resolves the backend name once, at startup, and holds those addresses for the life of the process. Scale the backend, or let a replica be replaced, and every request 502s while the backend is perfectly healthy -- the edge is talking to containers that no longer exist.

docker compose -f compose.scalable.yaml up -d --scale backend=3
docker compose -f compose.scalable.yaml restart edge

This is in the document because it happened during the bring-up above: two fresh replicas answering on their own ports, the edge returning 502 to everything, and nothing wrong with either half. deploy/edge/nginx.conf carries the per-request-resolution alternative in a comment, and why it is not the default.

What changes in the configuration

Variable Why it changes here
VIBE_LIVE_DB_DSN mysql://user:pass@mariadb:3306/vibelive. Setting it switches the backend off SQLite entirely -- there is no mixed mode, and no automatic copy from an existing SQLite file.
VIBE_LIVE_VALKEY_ADDR Required with more than one replica. Chat fan-out and rate limits become shared state. A failure to connect is fatal at startup on purpose: coming up anyway would look healthy while silently dropping every cross-replica message.
VIBE_LIVE_MEDIA_HOST Unset, it defaults to the host in VIBE_LIVE_PUBLIC_BASE_URL. Set it here if RTMP arrives on a different name from the site -- more likely in this shape than in the other, since the edge and the media server can sit behind different addresses.
VIBE_LIVE_RTMP_PORT Unset, the page tells streamers 1936. Set it when RTMP reaches this instance on another port -- a second instance behind the same router address, since RTMP carries no hostname and no proxy can share one port between two. It changes what is printed, not what anything listens on.

Four more this shape needs and the other does not, because this one runs its own database. scripts/quickstart.sh does not write them -- it generates the all-in-one .env, which uses SQLite -- so they go in by hand:

Variable Notes
MARIADB_ROOT_PASSWORD No default. The container refuses to initialise without it, and the failure is a restart loop whose log says which variables it would accept.
MARIADB_PASSWORD No default. The same value appears inside VIBE_LIVE_DB_DSN, which compose builds from it.
MARIADB_USER Defaults to vibelive.
MARIADB_DATABASE Defaults to vibelive.

Found missing on 2026-09-11 by bringing this stack up from a fresh quickstart.sh: MariaDB restart-looped, and backend and edge sat in Created forever because they wait for it to be healthy. .env.example listed all four the whole time; this page did not, and this page is what somebody deploying the split shape reads.

Everything else is the same as the all-in-one shape -- including username/password sign-in, its SMTP settings and the provider button's name and icon (F22), which this file's compose service passes exactly as the all-in-one one does. Until 2026-08-28 neither passed them, so setting them in .env did nothing here; a test now fails the build if a variable .env.example documents is missing from either compose file.

Replicas, and what makes them safe

Every replica runs the same background work: the live sweep, the publish confirmation, and the deferred-task runner. That is by design, not an oversight.

Migrations run at startup on every replica. Two replicas started simultaneously against a fresh MariaDB were observed to come up cleanly -- the versioned ledger means the second sees the work already recorded -- but the reliable way to upgrade a schema is still one replica first, then the rest.

MariaDB

The database is the deployment's data -- the vibe-live-db volume, or the host directory VIBE_LIVE_DB_HOST_DIR names (the MariaDB image takes it over at start). The Valkey container deliberately has no volume, because everything in it is in-flight state whose loss costs a few seconds of rate-limit memory.

compose.scalable.yaml gives MariaDB a healthcheck and makes the backend wait on it. That is not politeness: the backend runs migrations at startup and exits if the database is not there, so starting it too early is a restart loop rather than a wait.

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.