REST API backend that manages OBS cloud containers on demand. Each container runs a headless OBS Studio instance (Xvfb, NVIDIA GPU, Selkies viewer) and is owned by a single StreamWizard 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 — the Selkies viewer
and the OBS websocket are reached by proxying through this API itself
(/instances/:id/selkies/, /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, 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)
scripts/install.sh handles all of the above.
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 this node's NODE_ID, NODE_API_KEY and REST_API_URL
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.
Runs the manager against the local StreamWizard stack (local Supabase, rest-api, web panel) with instance containers that use the box's NVIDIA GPU directly. Every setting below defaults to the production value.
WSL2 has no NVIDIA DRM driver, so the shared gpu-xserver (real Xorg +
VirtualGL) cannot run. Instances get the GPU directly instead and OBS renders
through Mesa's d3d12 driver (/dev/dxg); NVENC works as usual. The Selkies
viewer stream is CPU x264, as in production.
- Build the instance image in the
obs-cloud-selkiesrepo:docker compose build(tagsobs-cloud-container-local:selkies). - Start local Supabase and rest-api in the monorepo
(
supabase start,cd apps/rest-api && bun run dev). - Link this box as a node and write
.env.local:scripts/local/register-node.sh --park-other-nodes. - Start the manager:
scripts/local/up.sh(API onhttp://localhost:3100).scripts/local/up.sh logs -f apifollows its logs.
| Variable | Production default | Local | Meaning |
|---|---|---|---|
OBS_INSTANCE_IMAGE |
ghcr.io/streamwizard/obs-cloud-container:latest |
obs-cloud-container-local:selkies |
Instance image. |
OBS_GPU_MODE |
xserver |
direct |
xserver: VirtualGL on gpu-xserver, bwrap jail. direct: GPU handed to the container, no jail privileges. |
OBS_GL_MODE |
(n/a) | d3d12 |
Direct mode only: d3d12 (WSL2 GPU, mounts OBS_WSL_LIB_DIR, default /usr/lib/wsl) or software. |
SELKIES_* |
Forwarded to every instance's Selkies, overriding the defaults in src/clients/instance-spec.ts. SELKIES_BASIC_AUTH_PASSWORD is ignored: each instance gets its own. |
||
ALLOW_MISSING_STREAM_KEY |
false |
true |
Boot without a Twitch stream key instead of refusing. Never on a real node. |
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 this node's NODE_ID, NODE_API_KEY and REST_API_URLThis 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. |
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, /metrics and /admin REST routes require Authorization: Bearer <node token>. A node token is a 60-second Ed25519 JWT that web-admin or web-streamwizard signs for one call: aud is this node's id, scope is the one action it allows (instances:control, files, ws-ticket, admin:instances, ...) and iid names the instance on any route with an :id. The node verifies it with the public keys it gets from GET /api/obs-nodes/me (one key set per environment, so a new node needs no extra config). A token for another node, another scope or another instance is refused (401/403). /admin routes also need role: "admin" in the token and the admin role in user_roles.
A Supabase session JWT is not accepted: every call needs a node token.
WebSocket upgrades (obs-websocket, the admin metrics stream) can't send headers, so they use a 30-second single-use ?ticket= from the matching POST .../ws-ticket route instead. The Selkies viewer carries its ticket in the URL fragment (see below).
- 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: a ws-ticket with scope
obsws(?ticket=), minted byPOST /instances/:id/ws-ticket - Behavior: upgrades to a WebSocket and bridges it 1:1 to the instance's internal OBS websocket port (
4455) overobs-net, for OBS Studio scene/source control. 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: a ws-ticket with scope
selkies(2-minute TTL), minted byPOST /instances/:id/ws-ticket, in the URL fragment: the panel opens/instances/:id/selkies/#token=<ticket>as its own window. Browsers never send the fragment to a server. - Behavior:
- Static client files (anything outside
/api/, GET or HEAD) load without the ticket. OIM serves them from its own copy of the Selkies web client, extracted from the Selkies release at image build time (Dockerfile,VIEWER_WEB_ROOT), never from the container. KeepSELKIES_VERSIONin step with the instance image. The page answers503with a retrying "OBS is starting" page until Selkies in the container is healthy. /api/*and the WebSocket go to the container's port8080overobs-net. They need the ticket, which the Selkies client sends asAuthorization: Beareron fetches (or in its ownselkies_tokencookie where it can't set a header) and as theselkies.token.<base64url>WebSocket subprotocol. The first use spends the ticket and opens a viewer session (services/auth/selkies-viewer-sessions.ts) that lives while a socket is open plus 2 minutes after the last one closes, capped at 12 hours. Every WebSocket connect checks instance ownership again (the admin role on the admin route), so revoked access ends with the socket.- HTTP answers from the container get
Content-Security-Policy: sandboxandX-Content-Type-Options: nosniff, so they are never run as a page. - Incoming cookies and
Authorizationare never forwarded; the proxy adds the instance's own Selkies basic auth (rest-api's per-instancevnc_password), so the browser never sees it. /admin/instances/:id/selkies/is the same for admins.
- Static client files (anything outside
- 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: a single-use
?ticket=fromPOST /admin/ws-ticket(scopemetrics), which itself needs anadmin:ws-ticketnode token and theadminrole inuser_roles. - 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 the ticket is minted per admin, the admin's browser can connect directly — no server-side relay needed.