REST API backend that manages OBS cloud containers on demand. Each container runs a headless OBS Studio instance (Xorg, NVIDIA GPU, x11vnc, noVNC) and is owned by a single Supabase-authenticated user. The backend also streams per-container and host resource metrics in real time over SSE.
Instance containers publish no ports to the host at all — noVNC and the OBS
websocket are reached by proxying through this API itself
(/instances/:id/novnc, /instances/:id/obsws) over a Docker-internal
network. The only port a node exposes is the API's own (3000), bound to
loopback and to the node's Tailscale address — never the LAN or a public
interface. Browsers and the panel apps reach it through a Cloudflare Tunnel
running on the node (cloudflared origin http://localhost:3000, hostname
set as the node's api_url in web-admin); tailnet hosts such as the alert
worker reach it over Tailscale. Tailscale is also what lets the OBS
containers pull SRT from an ingest node.
scripts/install.sh provisions a fresh Ubuntu host end to end (Docker, the
NVIDIA Container Toolkit, Tailscale, the obs-net Docker network, ufw rules,
and a dedicated obs service account):
sudo bash scripts/install.sh --startA node never gets a copy of this source. The installer fetches just
docker-compose.yml and .env.example from GitHub into
/opt/obs-instance-manager and pulls the prebuilt API image from GHCR
(ghcr.io/streamwizard/obs-instance-manager), which CI builds on every merge
to main (see .github/workflows/build-images.yml). Pass --ref to install
from a branch or tag other than main.
By default it only opens SSH to your auto-detected LAN network (override with
--ssh-cidr), allows the API port in on tailscale0 only, and expects you
to fill in .env by hand afterward (NODE_ID, NODE_API_KEY,
REST_API_URL, SUPABASE_URL, TAILSCALE_IP). Pass --tailscale-authkey
to have it join the tailnet for you in this manual mode. If a panel
implementing the claim handshake in docs/PANEL_INTEGRATION.md exists, pass
--rest-api-url and --token instead: the script links itself, joins
Tailscale with a key the panel mints, and reports its Tailscale IP back so
the panel knows where to reach it. It refuses --start until the node has a
Tailscale IP, since docker-compose.yml binds the API to that address. It
also points systemd-resolved at Cloudflare DNS and, on request, gives the
primary NIC a static address via netplan: pass --static-ip=10.0.0.5/24
(and --gateway= if the current default route isn't right), or answer the
question it asks on the terminal (--no-prompt skips it for unattended
runs). The static address is applied as the very last step because a changed
address drops your SSH session. Run scripts/install.sh --help for all
options.
This requires GPU passthrough already configured at the hypervisor level (the
script checks lspci and exits if no NVIDIA GPU is visible). The NVIDIA
driver itself is installed when missing: ubuntu-drivers install picks the
recommended package (override with --nvidia-driver=nvidia-driver-550, or
--skip-nvidia-driver to fail instead). The kernel module only loads after
a reboot, so the box reboots once and the install resumes by itself through
a oneshot systemd unit with the same arguments; follow it with
tail -f /var/log/streamwizard-install.log. Secure Boot must be off, since
an unattended run can't enrol a MOK key for the module.
sudo -u obs bash -c 'cd /opt/obs-instance-manager && docker compose pull && docker compose up -d'Nodes track the :latest tag, so this picks up whatever CI last built from
main. Note that a plain restart or host reboot re-uses the cached image — a
node only moves forward on an explicit pull.
To pin a node to a specific build instead, set OBS_IMAGE_TAG in
/opt/obs-instance-manager/.env to a tag CI published (e.g.
OBS_IMAGE_TAG=sha-abc1234) and re-run the command above. Leave it blank to
go back to tracking :latest. This never requires editing
docker-compose.yml.
scripts/uninstall.sh reverses install.sh: it stops the stack, removes the
OBS containers/images and the obs-net network, deletes /data/obs-configs,
/data/obs-plugins and /opt/obs-instance-manager, and removes the obs
service account. The installer drops a copy at
/opt/obs-instance-manager/uninstall.sh, so teardown needs no network access.
sudo bash scripts/uninstall.shBy default it leaves Docker, the NVIDIA Container Toolkit, Tailscale, and ufw
installed (other things on the host may depend on them). Pass
--purge-docker, --purge-nvidia-toolkit, --purge-tailscale,
--remove-ufw-rule, --remove-dns, and/or --disable-ufw (or --all for
all six) to fully reset a host back to a pre-install.sh state — useful when
testing the installer itself. --remove-static-ip (never part of --all,
since the address may change under your SSH session) drops the netplan file
--static-ip wrote. Run scripts/uninstall.sh --help for all options; it
always asks for confirmation first unless --yes is given.
- Bun (v1.3+) — only needed for local dev outside Docker
- Docker, with the NVIDIA Container Toolkit installed and the
nvidiaruntime registered with the Docker daemon nvidia-smiavailable on the host running this API (used directly via the CLI for GPU metrics)- cAdvisor running on the host, used for per-container CPU/RAM metrics (see below)
- A Supabase project (local or hosted)
scripts/install.sh handles all of the above except the Supabase project.
The included docker-compose.yml pulls the prebuilt API image and runs it alongside cAdvisor — this is the recommended way to run on the host.
cp .env.example .env
# fill in .env with your Supabase project values and this node's NODE_ID
docker compose up -dIn a checkout, docker-compose.override.yml sits next to docker-compose.yml
and adds build: . back to the api service, so docker compose up -d --build
still builds from local source for development. Compose merges that file
automatically and only ever finds it in a checkout — nodes receive just
docker-compose.yml, so they always run the prebuilt image.
This starts:
api— the REST API, fromghcr.io/streamwizard/obs-instance-manager(built from the includedDockerfile), with the host's Docker socket mounted (so it can manage sibling OBS containers) and GPU access passed through (gpus: all) fornvidia-smi. It joins both the internal-onlyinternalnetwork (to reach cAdvisor) andobs-net(to reach the instance containers it creates).cadvisor— used for per-container CPU/RAM metrics. It sits on an internal-onlyinternalDocker network shared withapiand has no port published to the host — only theapicontainer can reach it, athttp://cadvisor:8080(already wired up via theCADVISOR_URLenv var in the compose file).
Requirements on the host: Docker with the NVIDIA Container Toolkit installed (so gpus: all and nvidia-smi work inside the api container), nvidia-smi-capable drivers installed on the host itself, and the obs-net Docker network created (docker network create obs-net — scripts/install.sh does this for you).
GPU/VRAM metrics are unaffected by any of this — cAdvisor has no per-process GPU support, so VRAM-per-container still comes from cross-referencing nvidia-smi --query-compute-apps PIDs against each container's process list (via Docker top), run directly inside the api container.
If you'd rather run the API directly with Bun and cAdvisor as a separate container:
docker run \
--volume=/:/rootfs:ro \
--volume=/var/run:/var/run:ro \
--volume=/sys:/sys:ro \
--volume=/var/lib/docker/:/var/lib/docker:ro \
--volume=/dev/disk/:/dev/disk:ro \
--publish=8080:8080 \
--detach=true \
--name=cadvisor \
gcr.io/cadvisor/cadvisor:latestSet CADVISOR_URL (default http://localhost:8080) if it's reachable somewhere other than localhost.
bun install
cp .env.example .env
# fill in .env with your Supabase project valuesThis API shares its Supabase database with the main streamwizard monorepo, so the obs_nodes and obs_instances table migration lives there: supabase/migrations/20260623000000_obs_instances.sql. Apply it via that repo's Supabase CLI workflow (supabase db push / supabase migration up), not from this repo.
It creates the obs_nodes and obs_instances tables, enables RLS on both (obs_nodes is service-role only; obs_instances allows owners to read/delete their own rows), and seeds one default obs_nodes row. Before going to production, update the seeded gpu_bus_id to match the actual host:
nvidia-smi --query-gpu=pci.bus_id --format=csv,noheaderFor multi-node setups, see docs/PANEL_INTEGRATION.md — it documents the
schema changes (dropping the now-unused per-instance port-range columns) and
the node-claim handshake a future panel would implement.
bun run src/index.tsThe server listens on PORT (default 3000) and logs the port on startup.
| Variable | Description |
|---|---|
NODE_ID |
The obs_nodes.id row this process represents. Required — every instance-manager process serves exactly one node. |
SUPABASE_URL |
Supabase project URL |
NODE_API_KEY |
This node's key for authenticating to streamwizard-api (issued by the panel at claim time) |
REST_API_URL |
Base URL of the panel's streamwizard-api |
PORT |
Port the API listens on (default 3000) |
OBS_IMAGE_TAG |
Read by docker-compose.yml, not the app. Blank tracks :latest; set to sha-<short> to pin the node to a specific build |
CADVISOR_URL |
Base URL of the cAdvisor instance used for container CPU/RAM metrics (default http://localhost:8080) |
OBS_NETWORK |
Docker network shared with instance containers for the websocket proxy (default obs-net) |
PANEL_ORIGIN |
Comma-separated origin(s) allowed to call the REST API directly from a browser (CORS). Default * |
All /instances and /metrics routes require authentication: send the Supabase JWT either as Authorization: Bearer <token> or as a ?token=<token> query parameter (the query param exists because the browser's native EventSource cannot set custom headers).
- Auth: none
- Response:
{ "ok": true, "timestamp": "<ISO 8601>" }
- Auth: required
- Response: array of the user's instances, each with a live
docker_statusfield (running/stopped/not_found) merged in.
- Auth: required
- Response: the instance (only if owned by the caller) with a live
docker_statusfield.404if not found or not owned by the caller.
- Auth: required
- Body:
{ "resolution"?: string }— defaults to"1920x1080". - Behavior: loads this node's config (
NODE_ID), checksmax_instancesandtotal_vram_mbcapacity, creates and starts the Docker container on the sharedobs-netnetwork (no host ports published), and persists the instance row. - Response:
201with the full instance row.409if the node is at capacity or VRAM would be exceeded.
- Auth: required (
?token=works here too, since browsers can't set headers on a WebSocket handshake) - Behavior: upgrades to a WebSocket and bridges it 1:1 to the instance container's internal noVNC websocket port (
6080) overobs-net. This is the only way to reach noVNC — it's never published to the host. - Closes with code
4404immediately after upgrading if the instance isn't found or isn't owned by the caller.
- Auth: required (
?token=supported) - Behavior: same bridge as above, to the instance's internal OBS websocket port (
4455), for OBS Studio scene/source control.
- Auth: required
- Behavior: starts the container and sets
statustorunning. - Response: the updated instance row.
404if not found/owned.
- Auth: required
- Behavior: stops the container (10s grace period) and sets
statustostopped. - Response: the updated instance row.
404if not found/owned.
- Auth: required
- Behavior: stops (ignoring errors) and force-removes the container, then deletes the instance row.
- Response:
{ "success": true }.404if not found/owned.
- Auth: required
- Behavior: one-shot metrics collection across all of the caller's running instances plus the host.
- Response: a
MetricsPayload:
{
"timestamp": "2024-01-01T00:00:00.000Z",
"host": {
"gpu_name": "string",
"vram_used_mb": 0,
"vram_total_mb": 0,
"gpu_util_pct": 0,
"mem_controller_util_pct": 0,
"nvenc_avg_fps": 0,
"gpu_temp_c": 0,
"cpu_pct": 0,
"ram_used_mb": 0,
"ram_total_mb": 0
},
"containers": {
"<instance_id>": {
"cpu_pct": 0,
"ram_used_mb": 0,
"ram_limit_mb": 0,
"vram_used_mb": 0
}
}
}- Auth: required (use
?token=forEventSource) - Behavior: Server-Sent Events stream. Sends one
metricsevent immediately on connect, then every 3 seconds for as long as the client stays connected. Each event'sdatais a JSON-encodedMetricsPayload(same shape as/metrics/snapshot).
- Auth: required — a Supabase JWT (
Authorization: Bearer <token>or?token=), same as every other route here, but the caller must additionally have theadminrole inuser_roles.403if the JWT is valid but the user isn't an admin. - Behavior: WebSocket. Pushes a
MetricsPayloadimmediately on connect and then every 3 seconds, covering every instance on this node (not scoped to one user) — this is what a panel's admin Nodes page consumes. Since this is gated by the caller's own admin-scoped JWT rather than a node-wide secret, the admin's browser can connect directly — no server-side relay needed.