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:
- No TLS. The edge publishes plain HTTP on 8080 and something in front
of it terminates TLS.
compose.yamlships a caddy that gets its own certificate; this file does not, because a deployment big enough to need more than one replica already has an ingress, a load balancer or a terminator of its own, and a second one underneath it is a hop that only adds ways to be wrong. - The backend answers to two names.
vibe-live-engine-ome/deploy/ome/VHost.xmlposts admission tohttp://vibe-live:8081, and the service here is calledbackend-- so it carries a network alias. That keeps the admission URL written down once instead of once per topology, andome_config_test.gofails the build if either file stops answering to the name that file names. With several replicas the alias resolves to all of them and OME reaches whichever it likes, which is fine: the admission check is an HMAC and a database lookup with no per-replica state behind it.
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:
- Two backend replicas against one MariaDB and one Valkey, both healthy, both running their background loops.
- The edge routing:
/api/v1/instancereached the backend and returned JSON,/settingsreached the frontend and returned index.html, and/api/v1/mereturned 401 rather than an HTML page -- the failure mode that a wrong path list produces. - Cross-replica chat. A message posted over the WebSocket on replica A
arrived at a viewer connected to replica B. This is the entire reason
VIBE_LIVE_VALKEY_ADDRexists, and it had never been run before this. - Single execution of deferred work. A due task was claimed and run by exactly one of the two replicas; the other saw it already claimed and moved on.
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:
/ws/needsUpgrade/Connectionforwarded and a longproxy_read_timeout. Without the headers nginx answers 400 and chat never connects; with nginx's default 60s read timeout, an idle chat socket is closed roughly every minute.- The OME prefixes stay pointed at the backend even when the corresponding
VIBE_LIVE_OME_*_BACKEND_URLis unset. The backend then answers 404, which is honest; routing them to the frontend would answer index.html and pretend the endpoint exists.
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.
- Deferred tasks are claimed with a compare-and-swap in the database, so two replicas racing for the same task produce one winner and one no-op. The observed run above is that property working.
- Chat rooms and rate limits go through Valkey, so which replica a viewer's WebSocket landed on stops mattering. Every limiter, not most of them: a ceiling counted per replica is one an attacker multiplies by being routed somewhere else, so a test walks the server's limiters and fails if it finds one that is not wired (which is how the second-factor limiter was caught, hours after it was added).
- Sessions are signed cookies, so no sticky routing is needed. All
replicas need the same
VIBE_LIVE_COOKIE_SECRET, which they get from the same environment. - The publish-confirmation map is deliberately in-process and short lived. A replica restarting loses its own view of in-flight publishes; the live sweep is the backstop that clears anything stuck.
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.