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.
- nesting lets the container create its own namespaces and mount its own filesystems, which is most of what a container runtime does.
- keyctl gives containerd the kernel keyring.
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:
/opt/vibe-livetravels, so they get the repository and the compose file without cloning anything..envtravels if you made one, and it holds every secret this instance has -- includingVIBE_LIVE_DB_ENCRYPTION_KEY. Delete it before the dump unless you mean to share the instance rather than the image, and let them runquickstart.shthemselves. Their instance should not have your cookie secret.- The SQLite database travels if the stack has ever run, inside the
Docker volume: accounts, channels, chat history, stream keys. Same
reasoning --
docker compose down -vbefore the dump for a clean image. - The pulled images travel, which is most of the size and the reason the restored container starts without downloading anything.
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.