vibe-live docs

Deploying vibe-live: a Proxmox LXC

Proxmox is the shape most homelabs already have spare capacity in, and there are two ways to put vibe-live in an LXC there. They differ by one thing: whether there is a container runtime inside the container.

What runs in the LXC Costs
The appliance -- build-lxc-template.sh + create-lxc-appliance.sh the image's own s6 as PID 1, supervising the app and OvenMediaEngine. No Docker, no compose, no runtime TLS. The image serves plain HTTP; something in front has to terminate it
Docker in the LXC -- create-lxc.sh Debian, Docker, this repository, and compose.yaml inside it nesting + keyctl, a storage driver that degrades on ZFS, and a container runtime nested in a container

Take the appliance unless you want caddy. It is the shape the image was built for -- its entrypoint is /init, the Dockerfile carries no ENV block precisely so a bare rootfs is self-sufficient, and pct create from a template does the uid shifting that doing this by hand famously does not. The only thing the Docker route gives you that this does not is caddy, and with it a certificate that renews itself.

The appliance: no Docker anywhere

Two steps, on two machines, because a Proxmox host has no podman and your workstation is not a hypervisor.

1. Package the published image as a template, wherever podman or docker already is:

./deploy/proxmox/build-lxc-template.sh
# -> vibe-live-latest.tar.zst

That pulls forgejo.gmatte.xyz/gmatte/vibe-live:latest and exports its filesystem. It needs no credentials -- the package registry serves the image anonymously even though the git repository beside it is private, which is the one way this route is friendlier than the Docker one. Pass any other tag to pin a version instead of tracking latest. Verified 2026-09-20: 77 MB of template, carrying /init, the binary, ffmpeg, OvenMediaEngine and both s6 service definitions, and no container runtime of any kind.

2. Create the container, on the Proxmox host as root:

scp vibe-live-latest.tar.zst root@proxmox:/var/lib/vz/template/cache/
./deploy/proxmox/create-lxc-appliance.sh 200 \
    /var/lib/vz/template/cache/vibe-live-latest.tar.zst

It asks for the public URL, generates the four secrets, writes them into the container's config as lxc.environment lines, starts it, and waits for the app to answer /livez before telling you it worked. Then:

pct exec 200 -- vibe-live admin create <you> --admin

No pct enter, no compose: the binary is a binary in that container.

Changing configuration afterwards is editing /etc/pve/lxc/200.conf and pct restart 200. Those lines hold VIBE_LIVE_DB_ENCRYPTION_KEY, so that file is as sensitive as an .env, and it travels inside a vzdump.

Updating is a new template and a new container, not an upgrade in place: build the template from the newer tag, create a second CT from it, and move the /data directory across (pct stop both, then pct exec ... tar). The database and its key are the only state.

Docker in the LXC, if you want caddy

./deploy/proxmox/create-lxc.sh 200 vibe-live

Run that on the Proxmox host, as root, where pct exists. It creates an unprivileged Debian 13 container, installs Docker, clones this repository to /opt/vibe-live, and then proves Docker actually works before telling you it is ready. Then:

pct enter 200
cd /opt/vibe-live
./scripts/quickstart.sh
docker compose up -d
docker compose exec vibe-live vibe-live admin create <you> --admin

Defaults, all overridable as environment variables: STORAGE=local-lvm, TEMPLATE_STORAGE=local, BRIDGE=vmbr0, DISK_GB=20, MEMORY_MB=4096, CORES=4, REPO_URL=https://forgejo.gmatte.xyz/gmatte/vibe-live.git.

REPO_URL is the one you are likely to need. This repository is private until v1, so the default URL clones only for somebody already authenticated -- and a fresh container is nobody. The appliance route above has no such problem, which is most of why it is the one to take today. Give it a URL carrying a token (https://<user>:<token>@.../vibe-live.git), point it at your own fork or mirror, or let that step fail and copy a working tree in from the Proxmox host, which the script's error tells you how to do. Everything before it -- the container, Docker, the nesting features -- is already done by then, so this is never a reason to start over.

The trap: Docker inside an unprivileged LXC

This section is about the Docker route only. The appliance needs neither feature, which is most of the argument for it.

--features nesting=1,keyctl=1. The script sets both; this section is for when you build the container by hand instead and it does not work.

Without them pct create succeeds, the container boots, Docker installs cleanly and docker run fails at container start -- usually as a permission denied on a mount, which reads like a broken image rather than a missing container feature. That is why create-lxc.sh ends by running hello-world: a failure there is worth one clear error now instead of an hour later.

Do not reach for --unprivileged 0 when that happens. It is the advice you will find, it does work, and it makes the container's root the host's root if anything ever escapes it. Fix the features instead.

On ZFS-backed storage, Docker may fall back to the vfs storage driver, which copies whole layers instead of overlaying them: correct, slow, and hungry for disk. docker info names the driver it chose. It is not worth fighting for a test instance; for anything long-lived, put the container's rootfs on ext4/LVM.

Memory

MEMORY_MB=4096 is not a guess. A software transcode of one 720p stream is roughly a core, and this project's own cluster has OOM-killed OvenMediaEngine inside a 4Gi limit -- see backlog item O9, where the cause was a vertical stream sending the thumbnail encoder into an endless churn of scaler contexts. That specific bug is fixed in the configuration shipped here (vibe-live-engine-ome/deploy/ome/VHost.xml sets a height and no width, twice, for exactly that reason), but 4Gi is the number this stack is known to live in rather than a number that was tested to be enough.

Idle, the whole stack sits near 300Mi. The rest is headroom for whoever is publishing.

The five ports, and where they have to arrive

The container gets its own address on the bridge. Everything in all-in-one.md about ports applies to that address:

For
80/tcp the ACME challenge, and the redirect to HTTPS
443/tcp the site
1936/tcp RTMP -- where OBS connects
10000/udp WebRTC media
3478/tcp WebRTC over TCP, for whoever's network blocks UDP

Behind a home router that means five forwards to the container's IP, not to the Proxmox host. Two hops is one more place to get it wrong than the document assumes, and the symptom of forwarding to the host instead is a certificate that never issues.

The last one is the only one the instance works without: skip it and viewers on a UDP-blocked network get HLS instead of sub-second video, and streamers on one cannot go live from the browser at all. It is listening either way -- the bundled media server defaults OME_TCP_RELAY to *:3478 and advertises the relay itself.

Trying it without a domain

The stack asks for a public hostname because that is what a real instance has. For a test on the LAN you do not need one, at the cost of the browser's padlock. Two changes:

On the appliance there is no caddy to configure, so this is the Docker route's version; there, point whatever terminates TLS at the container's 8080 and give it a self-signed certificate.

1. Let caddy issue its own certificate. In deploy/caddy/Caddyfile, add tls internal inside the site block:

{$VIBE_LIVE_PUBLIC_BASE_URL} {
	tls internal
	reverse_proxy vibe-live:8080 {
		flush_interval -1
	}
}

and answer quickstart.sh with the container's IP address instead of a hostname. Caddy will serve an HTTPS certificate from its own CA. Every browser will warn once, and after you accept it the page is treated as a secure context -- which matters more than it sounds, because publishing from the browser needs one: getUserMedia does not exist on a page that is not secure, so plain HTTP is not a shortcut here, it is a missing feature.

2. Advertise the LAN address to WebRTC. Set this in .env:

OME_ICE_CANDIDATE=<the container's IP>:10000/udp

Without it, OvenMediaEngine asks a STUN server what it looks like from outside and advertises your public address -- correct for a real deployment, useless on a LAN with no hairpin NAT, and the failure is a stream that negotiates, connects, and shows nothing.

On the built-in engine the same setting is VIBE_LIVE_WEBRTC_PUBLIC_IPS=<the container's IP>, and it takes a list, so a container that has to serve both a LAN and a public address can name both.

RTMP needs no change: quickstart.sh writes the address into VIBE_LIVE_PUBLIC_BASE_URL, and the settings page derives what it tells OBS from that, so OBS is told the container's IP and connects straight to it.

Validating the install

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

The same check every other shape gets, and it matters most here: this guide has you forwarding five ports through Proxmox by hand, and four of the five carry media rather than the page. A mistake in any of them leaves a site that loads perfectly and cannot go live. The script names which route failed.

Run it from anywhere that can reach the host -- it needs curl and a URL, not a shell on the container.

Handing the container to somebody else

pct stop 200
vzdump 200 --mode stop --compress zstd --dumpdir /var/lib/vz/dump

One file, which they pct restore onto their own Proxmox. This describes the Docker route's container; an appliance carries the same risks in different places -- its secrets are lxc.environment lines in the config the dump includes, and its database is /data in the rootfs. What travels and what does not is worth knowing before you hand it over:

The appliance by hand, and the two things it costs an hour to learn

create-lxc-appliance.sh exists so nobody has to do this, but the route was first walked by hand on 2026-09-11 -- validated against an unprivileged LXC: both processes start, the app answers /livez, and the media server binds RTMP, LLHLS and TURN. Doing it manually is unpacking the image and pointing an LXC at the result:

cid=$(podman create forgejo.gmatte.xyz/gmatte/vibe-live:latest)
mkdir -p ~/.local/share/lxc/vibe-live/rootfs
podman export "$cid" | tar -C ~/.local/share/lxc/vibe-live/rootfs -xf -
podman rm "$cid"
podman unshare chown -R 0:0 ~/.local/share/lxc/vibe-live/rootfs

The uid mapping has to match how the files were written, and this is the trap the script does not have. podman unshare puts container root at your own uid, so the LXC config must say the same -- lxc.idmap = u 0 <your uid> 1 then u 1 100000 65535, and the same for groups. Any other mapping makes every file in the image appear as nobody, and the first thing s6 does is fail to take ownership of /run. Going through a template and pct create --unprivileged 1 instead hands that problem to Proxmox, which already solves it for every other container on the host.

An OCI image's environment is NOT in its filesystem. It lives in the image config, which a rootfs export does not carry, so every ENV line disappears on this route. That is why this project sets the bundled defaults in files rather than in the Dockerfile -- the engine's tier ships them as /etc/vibe-live/engine.env, which the app's start script sources -- so the filesystem is self-sufficient, and the same defaults apply whether it is started by Docker, by compose, or by lxc-start. Anything you set in the LXC config still wins. It is also why the appliance is configured with lxc.environment lines rather than anything inside the rootfs.

Only the all-in-one image works this way. The route depends on s6 being the filesystem's init, which the all-in-one-ffmpeg image does not have: its entrypoint is the binary itself, so as an LXC rootfs it would boot to nothing. For the built-in engine in an LXC, run it under Docker, or write the one service file yourself -- it is a single process with no supervisor to arrange.