Skip to main content

Run the Local Dev Stack

The entire backend stack runs in Docker. A new developer installs Docker, drops in one secrets file, and runs a single command — no Go, Postgres, Redis, LiveKit, or egress installed on the machine.

Scope: local development of the qubital-backend service. Paths below are relative to the qubital-backend repo root. Verified against: Go 1.25 · livekit/livekit-server:v1.8.3 · supabase/postgres:15.14.1.132 · 31 migrations.

Prerequisites

  • WSL2 (Ubuntu) or Linux — the stack uses host networking. Windows-native is unsupported (use WSL2); macOS needs Colima/Lima — see Note for macOS users.
  • Docker Engine (see 1. Install Docker).
  • The backend .env secrets file from the team secret manager (see .env.example in the repo root).
  • A clone of the repo on the WSL Linux filesystem (~/…, /opt/…) — not /mnt/c/... (breaks hot-reload file-watching).

Navigation:


Configuration & secrets (.env)

The dev stack overrides only the local config in docker-compose.yml (Postgres, LiveKit, Redis, MinIO endpoints/creds). Everything else — including every cloud-only service — is read from the backend's repo-root .env, which is bind-mounted into the api/worker containers and loaded via godotenv. The compose file does not re-declare those secrets; it points at root .env.

You must add the WorkOS secrets to the repo-root .env. WorkOS is a cloud-only auth provider, so even in local dev the stack talks to your WorkOS environment over the network (it is not overridden to anything local). Without these the api fails to boot (env validation):

# qubital-backend/.env (repo root, gitignored)
WORKOS_API_KEY=...
WORKOS_CLIENT_ID=...
WORKOS_SSO_REDIRECT_URI=...
WORKOS_INTEGRATIONS_REDIRECT_URI=...
WORKOS_REDIRECT_URI_WEB=...
WORKOS_FAKE_LOGOUT_REDIRECT_URI=...

The same root .env holds the rest of the backend secrets (JWT, cookies, Grafana, prod R2, …). The dev stack overrides R2 → local MinIO, but leaves WorkOS pointing at the cloud. See .env.example in the repo root for the full list.

Note for macOS users

The stack is built for a native Linux Docker engine (host networking — see §7). macOS has no equivalent of WSL2: Docker Desktop always runs the engine inside a hidden Linux VM, so host-networked services live in that VM, are awkward to reach from macOS, and WebRTC video to a browser on the Mac will not work. Two options:

  • Recommended: run a real lightweight Linux VM with Colima (or Lima) and run Docker inside it — this gives the same native-Linux environment WSL2 gives Windows users, and the stack behaves identically.
  • Accept the limits: only the bridged services (Postgres 5433, Redis 6380) are cleanly reachable from macOS. The host-networked ones (API, MinIO, LiveKit) depend on enabling Docker Desktop host networking, and browser video / recording won't work regardless — so in practice Colima is the way.

1. Install Docker

The stack is built and tested on WSL2 (Ubuntu) with Docker Engine running inside WSL. That's the recommended setup.

# Install Docker Engine inside WSL2 (Ubuntu)
curl -fsSL https://get.docker.com | sh

# Let your user run docker without sudo (re-open the shell afterwards)
sudo usermod -aG docker "$USER"

# Start the daemon (WSL doesn't auto-start it unless systemd is enabled)
sudo service docker start

# Verify
docker run --rm hello-world
docker compose version

Docker Desktop instead? Not recommended. On Windows it runs on WSL2 under the hood anyway, so just install WSL2 (above). Its host-networking support is VM-scoped, so the API/DB are usable but browser video / LiveKit recording won't work reliably — see §7. On macOS, use Colima/Lima instead (see the macOS note above).


2. Quick start

# 1. Clone onto the WSL Linux filesystem (NOT /mnt/c).
git clone git@github.com:StartingQuoTechDivision/qubital-backend.git
cd qubital-backend

# And put the backend secrets file in the repo root (get it from the team
# secret manager — it is gitignored and never committed). See .env.example.
# → qubital-backend/.env

# 2. Bring the whole stack up.
cd dev
docker compose up # first run pulls images + builds the Go image (a few minutes)

When it settles, check it's alive:

curl localhost:3001/health # → {"status":"ok"}

That's it — Postgres, Redis, LiveKit, egress, the API (:3001), and the worker are all running. First run is slow (image pulls + Go module download); every run after is fast.

WhatWhere
Backend APIhttp://localhost:3001
Postgreslocalhost:5433 · db postgres · user/pw postgres
LiveKitws://localhost:7880
RecordingsMinIO bucket recordings — browse at http://localhost:9001 (minioadmin/minioadmin)
Object storage (MinIO)S3 API localhost:9000 · console localhost:9001

3. Daily development

Source is bind-mounted into the containers and air hot-reloads the api/worker: edit any .go file → it rebuilds and restarts automatically.

cd dev

docker compose up -d # start in background
docker compose logs -f api # tail one service: api|worker|livekit|egress|postgres|redis
docker compose ps # what's running + health
docker compose restart api # restart one service
docker compose down # stop everything (keeps all volumes/data)
docker compose up -d --build # rebuild the Go image (after Dockerfile or dependency changes)

# Wipe ONLY the database (fresh migrations next start), keep the Go build caches:
docker compose down && docker volume rm qubital-dev_db-data

# Nuke everything incl. Go module/build caches (slow next build):
docker compose down -v

Data persistence

Postgres data lives in the named volume qubital-dev_db-data (mounted at /var/lib/postgresql/data), which is separate from the container lifecycle — so anything you add (a room, test data, …) survives normal stop/restart cycles. The seed only inserts/upserts (it never deletes), so it won't clobber your manual rows on the next up.

CommandYour data (e.g. a room you added by hand)
docker compose stop / restart✅ kept
docker compose downup✅ kept (removes containers, not volumes)
docker compose down -v❌ wiped — deletes all volumes → fresh DB, re-migrate, re-seed
docker volume rm qubital-dev_db-data❌ wiped (DB only)

Only the explicit wipe commands lose data. Migrations are version-checked (no-op if already applied) and the seed is purely additive, so a plain down/up keeps everything.

One caveat: a manual row lives only in the volume. To have it recreated after a full reset (down -v) — or so teammates get it too — bake it into cmd/devseed (same pattern as the "Dev Office" room) or a migration.


3.1 Dev users & login

The seed service (Go cmd/devseed) populates the local DB with a ready-to-use dataset so you can log in immediately:

  • org Qubital Development Environment, room Dev Office, and four users {admin,moderator,member,guest}@dev.com with matching roles + licenses.
  • It resolves each user's current workos_id and the workos_org_id by email lookup against WorkOS — no hardcoded IDs (so it never rots if the WorkOS tenant is re-provisioned). Idempotent.

It runs automatically after the api is healthy on docker compose up, or on demand:

docker compose run --rm seed

Prerequisite: the four …@dev.com users + the org must already exist in your WorkOS dev environment (login authenticates via WorkOS magic-auth; the seed only mirrors them locally).

Then log in as any role (no email round-trip — dev-only, gated by APP_ENV):

curl -X POST http://localhost:3001/auth/dev-sign-in \
-H 'Content-Type: application/json' -d '{"role":"admin"}' # or moderator|member|guest

4. How it all works

Everything below is optional reading — the stack runs without it. It explains the design and the why behind each piece (most of which exist because of a concrete problem we hit and fixed).

4.1 Architecture

It's one Compose project (qubital-dev) with 9 services in two networking groups — seven long-running containers plus two one-shot setup jobs (minio-setup, seed).

docker compose up (run from dev/)

┌──────────────────────────────┼─────────────────────────────────┐
│ HOST NETWORK (everything talks over localhost) │
│ │
│ api :3001 ──webhook──▶ (itself) worker (eventsync) │
│ │ │ │
│ │ LIVEKIT_URL=ws://127.0.0.1:7880 │ │
│ ▼ │ │
│ livekit :7880 ◀──WebRTC / record── egress ──┤ │
│ │ │ │ │
└────┼──────────────────────────────┼──────────┼───────────────────┘
│ redis 6380 redis 6380│ │ postgres 5433
▼ ▼ ▼
┌─────────────┐ ┌─────────────────────┐
│ redis │ (bridge 6380→6379) │ postgres │ (bridge 5433→5432)
│ 7-alpine │ │ supabase/postgres │
└─────────────┘ └─────────────────────┘
ServiceImageNetworkJob
redisredis:7-alpinebridge 6380→6379shared bus for livekit + egress
postgressupabase/postgresbridge 5433→5432the database
livekitlivekit/livekit-serverhostmedia server (--dev)
egresslivekit/egresshostrecords rooms → MinIO (S3 upload)
apibuilt (Go + air)hostthe backend, port 3001, hot-reloads
workerbuilt (Go + air)hostWorkOS event-sync, hot-reloads
miniominio/miniohostS3-compatible store for recordings + avatars (:9000 API, :9001 console)
minio-setupminio/mchostone-shot: creates the recordings / avatars buckets, then exits
seedbuilt (Go)hostone-shot: mirrors the WorkOS dev users/org into the local DB, then exits

4.2 Networking — why host + bridge

  • livekit, egress, api, worker → host networking. LiveKit/egress WebRTC works cleanly host-networked (the LiveKit-recommended Linux self-host model), and every service reaches every other over localhost — no service DNS, no UDP port-range mapping. (WSL2 with native Docker Engine fully supports host networking.)
  • redis (6380), postgres (5433) → bridge with remapped host ports. So they don't collide with a native redis on 6379 or a system postgres on 5432 a dev might already run.

Net result: every address is just localhost:<port>.

4.3 Startup order

Enforced by depends_on + healthchecks:

  1. redisredis-cli ping → healthy.
  2. postgres → on a fresh volume the supabase image initializes its roles / extensions / schemas, then our db-init.sh runs last → grants postgres superuser + creates the realtime stubs → server accepts connections → healthy.
  3. livekit (after redis healthy) → connects redis, advertises its node IP.
  4. api (after postgres healthy + livekit started) → air builds & runs it → loads config → applies migrations 1→31 → listens :3001/health healthy.
  5. worker (after postgres healthy + api healthy) → builds & runs; its migration call no-ops (api already ran them) → starts the event-sync loop.
  6. egress (after redis healthy + livekit started) → idles until a recording job.

4.4 Config & secrets — two .env files, different jobs

../.env APP SECRETS (WorkOS, R2, JWT, Grafana…). Gitignored.
Bind-mounted to /app/.env; the app loads it via godotenv.
dev/.env COMPOSE TUNABLES (LIVEKIT_NODE_IP). Optional, create locally; no secrets.
compose
environment: LOCAL OVERRIDES (LIVEKIT_URL, POSTGRES_URL, dev keys, APP_ENV…).

The trick: the app calls godotenv.Load("../../.env"), and godotenv does not override variables already set in the environment. So the base ../.env supplies the cloud secrets while Compose's environment: supplies the local overrides — and the overrides win automatically. The real .env is never edited.

(We mount the file rather than using Compose env_file: because the base .env contains a multi-line JWT_PRIVATE_KEY that env_file: can't parse but godotenv can.)

4.5 Hot reload

..:/app bind-mounts the repo into the api/worker containers; air watches .go files and rebuilds/restarts on save. Two cache volumes (go-mod, go-build) keep module downloads and compilation fast across restarts. Configs: air.api.toml, air.worker.toml.

4.6 The database story

The app runs its production migrations unmodified at boot (golang-migrate). Those migrations assume a real Supabase database, which drives three choices:

  1. supabase/postgres image (not plain postgres): the migrations CREATE EXTENSION pg_cron / pg_net, use vault, the realtime schema, and the anon/authenticated roles. Plain Postgres dies on migration 8; the supabase image ships all of it.
  2. Realtime stubs (db-stubs.sql): even the supabase image lacks realtime.messages / realtime.send() / realtime.topic() — in a real Supabase those are made by the separate Realtime service, which we don't run. Migration 11 needs them, so we create no-op versions.
  3. postgres granted superuser (db-init.sh): in the image postgres isn't a superuser and the realtime schema is owned by supabase_admin, so migration 11's CREATE POLICY is denied. db-init.sh runs as a Postgres initdb.d script, connects as supabase_admin (trusted on the local socket during init), grants postgres superuser, and applies the stubs — automatically, only on a fresh volume. (Fine for a throwaway local DB; production is untouched.)

What you lose locally (and it's fine for backend dev): pg_cron jobs are created but don't do anything meaningful, and chat messages persist but don't broadcast (the realtime.send stub is a no-op — there's no local Realtime subscriber).

4.7 Design decisions & gotchas (the "why")

DecisionReason
-buildvcs=false in airgo build tried to read git metadata from the bind-mounted .git as root → error obtaining VCS status: exit 128. The flag skips VCS stamping.
worker waits for api healthyapi and worker both run migrations on boot; migration 20 is CREATE INDEX CONCURRENTLY and two simultaneous migrators deadlock. Gating the worker makes api the sole migrator. Temporary — removed once the worker stops running migrations.
no webhook tunnelself-hosted livekit posts webhooks straight to localhost:3001/webhook; the "can't reach localhost" limit is LiveKit Cloud-only (verified against LiveKit source).
air full_bin cd trickthe app loads ../../.env relative to its working dir, so full_bin does cd /app/cmd/api before exec, guaranteeing it resolves to /app/.env.
node-ip (WebRTC media/ICE IP)livekit auto-detects the WSL IP at startup (default route, inside the host-net container) so browser/Electron clients can reach media; 127.0.0.1 only works for same-host egress. Override with LIVEKIT_NODE_IP in dev/.env.

4.8 Cloud dependencies & local object storage

ServiceWhyEffect locally
WorkOSauth provider, can't self-hostrequired; dev-env keys live in ../.env
Grafana Mimirmetrics backendmetrics ship to the cloud

Only WorkOS is needed for normal auth flows. Object storage runs locally: the dev stack replaces cloud R2 with a MinIO container, so recordings and avatars never leave your machine.

Recordings → local MinIO (instead of R2). In production the backend uploads egress recordings to Cloudflare R2 via an EncodedFileOutput_S3. Locally the same code path targets MinIO — only the endpoint + creds change:

  • the api's R2_* env is overridden to http://127.0.0.1:9000 (minioadmin/minioadmin, bucket recordings).
  • the dev stack sets R2_FORCE_PATH_STYLE=true, so egress (internal/platform/livekit/client.go) uses S3Upload.ForcePathStyle=true (MinIO on a localhost endpoint has no bucket-subdomain DNS; path-style is required). Prod leaves the var unset → false → virtual-hosted, R2's documented style. The Go R2 client always uses UsePathStyle, so presigned list/download work against MinIO unchanged.

So record → upload → list → presigned-download runs fully locally, exactly as in prod. Browse recordings at the MinIO console http://localhost:9001 (minioadmin/minioadmin), or mc alias set l http://127.0.0.1:9000 minioadmin minioadmin && mc ls l/recordings.

Caveat: the backend's room-composite egress points headless Chrome at a customBaseUrl template (the recording web view — a frontend artifact). With no working template you get Start signal not received, independent of storage.

Verify the storage path with a manual participant egress → MinIO (no backend/template):

docker run -d --rm --name lk-pub --network host \
-e LIVEKIT_URL=ws://127.0.0.1:7880 -e LIVEKIT_API_KEY=devkey -e LIVEKIT_API_SECRET=secret \
livekit/livekit-cli room join --identity demo-bot --publish-demo qubital-test
cat > /tmp/req.json <<'JSON'
{ "room_name":"qubital-test","identity":"demo-bot",
"file_outputs":[{ "filepath":"test-{time}.mp4",
"s3":{ "access_key":"minioadmin","secret":"minioadmin","bucket":"recordings",
"region":"us-east-1","endpoint":"http://127.0.0.1:9000","force_path_style":true }}]}
JSON
docker run --rm --network host -e LIVEKIT_URL=ws://127.0.0.1:7880 -e LIVEKIT_API_KEY=devkey -e LIVEKIT_API_SECRET=secret \
-v /tmp/req.json:/req.json livekit/livekit-cli egress start --type participant /req.json
# wait, then: egress stop --id <EG_...> → the MP4 lands in the MinIO `recordings` bucket
docker rm -f lk-pub

4.9 File map

FileRole
docker-compose.ymlthe whole stack: services, networking, volumes, healthchecks, ordering
Dockerfile.backenddev image: golang:1.25 + curl + air (source is bind-mounted, not copied)
air.api.toml / air.worker.tomlhot-reload config (what to build, how to run)
db-init.shone-time privileged DB init (superuser + stubs), runs inside postgres initdb
db-stubs.sqlthe 3 realtime objects the standalone image lacks
livekit.yamlLiveKit keys + webhook URL
egress.yamlegress → livekit/redis addresses + recording output
.envCompose tunables (LIVEKIT_NODE_IP) — not app secrets
.runtime/gitignored scratch: logs, build cache, recordings

5. Troubleshooting

Each entry is symptom → diagnose → fix → verify. Run commands from the dev/ directory unless noted.

Cannot connect to the Docker daemon

  • Fix: sudo service docker start (WSL doesn't auto-start it unless systemd is enabled).
  • Verify: docker ps returns without error.

First docker compose up seems stuck for several minutes

  • Cause: one-time pull of ~5 GB of images (supabase/postgres, golang, egress) + Go module download — not a hang.
  • Diagnose: docker compose logs -f shows pull/download progress.
  • Verify: subsequent ups settle in seconds.

api crash-loops on boot / exits immediately

  • Diagnose: docker compose logs api | grep -iE "missing required|failed to setup|env"
  • Cause: the base ../.env is missing or incomplete — required vars fail at startup.
  • Fix: get the full .env from the team secret manager, place it at the repo root (qubital-backend/.env); cross-check keys against .env.example.
  • Verify: docker compose up -d api && curl -s localhost:3001/health{"status":"ok"}.

api won't go healthy — "Dirty database version N. Fix and force version."

  • Diagnose: docker compose exec -T postgres psql -U postgres -d postgres -c "select version, dirty from public.schema_migrations;"
  • Cause: a migration failed partway and left the schema dirty.
  • Fix (throwaway local DB): docker compose down && docker volume rm qubital-dev_db-data && docker compose up -d
  • Verify: api becomes healthy; dirty = f in schema_migrations.

port is already allocated (5433 / 6380 / 7880 / 3001)

  • Diagnose: ss -ltnp | grep -E ':(5433|6380|7880|3001)'
  • Cause: a native service (system Postgres/redis, or a leftover container) holds the port.
  • Fix: stop the conflicting service, or change the host-port mapping in docker-compose.yml (e.g. 5434:5432).
  • Verify: docker compose up -d starts without the port error.

Browser/Electron can't join a room (WebSocket "failed" / ICE timeout)

  • Diagnose: docker compose logs livekit | grep -oE '"nodeIP": "[^"]+"' and check the client OS in the join log ("os":"Windows").
  • Cause: livekit advertised a node-ip the client can't reach. Signaling (ws://…:7880) connects, then media ICE fails. 127.0.0.1 is unreachable from a Windows client.
  • Fix: node-ip auto-detects the WSL IP; if it picked the wrong one, set LIVEKIT_NODE_IP=<wsl-ip> in dev/.env (ip -4 addr show eth0) and docker compose up -d livekit. Leave the client's serverUrl as ws://127.0.0.1:7880.
  • Verify: docker compose logs livekit | grep "advertising node-ip" shows the WSL IP; the call connects.

seed service fails / dev users missing

  • Diagnose: docker compose logs seed
  • Causes & fixes:
    • WorkOS user "…@dev.com" not found → the fixed dev users/org don't exist in your WorkOS dev environment — create them there.
    • missing WORKOS_API_KEY → the base ../.env lacks the WorkOS key.
    • Postgres connection refused → migrations not finished yet; the service waits on api: healthy, so just re-run docker compose run --rm seed.
  • Verify: docker compose run --rm seed logs ✔ seeded org ….

Can't log in as a dev user

  • Diagnose: curl -i -X POST localhost:3001/auth/dev-sign-in -H 'Content-Type: application/json' -d '{"role":"admin"}'
  • Cause: seed hasn't run (no local user/membership/license), or the role is invalid.
  • Fix: run the seed (above); role must be one of guest|member|moderator|admin.
  • Verify: the response sets auth cookies and returns the user.

Room creation fails — FK violation fk_rooms_valid_combination

  • Diagnose: docker compose exec -T postgres psql -U postgres -d postgres -c "select * from private.valid_room_combinations;"
  • Cause: the room's layout/dimension/style combo isn't in the reference table (a fresh DB has only the seeded one).
  • Fix: add the combo in cmd/devseed (same pattern as the existing row) and docker compose run --rm seed.
  • Verify: the combo appears in valid_room_combinations; room-create succeeds.

Recording missing / not in MinIO

  • Where to look: recordings upload to the MinIO recordings bucket, not a local folder — browse http://localhost:9001 (minioadmin/minioadmin). They are not in dev/.runtime/recordings/.
  • Diagnose: docker compose logs egress | grep -E "request validated|egress_aborted|Start signal|uploaded"
  • Causes:
    1. Room-composite aborts with Start signal not received — the recording template (a frontend artifact) didn't connect/signal. This is the current known blocker for backend-driven recordings; unrelated to storage.
    2. Buckets missing → docker compose up -d minio-setup (re-creates recordings/avatars).
  • Verify storage works (bypasses the template): run the manual participant-egress→MinIO snippet in §4.8, then check the bucket.

Hot reload (air) doesn't pick up .go changes

  • Diagnose: pwd — is the repo under /mnt/c/...?
  • Cause: inotify file-watching doesn't fire on the Windows-mounted filesystem.
  • Fix: clone onto the WSL Linux filesystem (~/…, /opt/…).
  • Verify: edit a .go file → docker compose logs -f api shows a rebuild.

My data disappeared after a restart

  • Cause: you ran docker compose down -v (or removed the volume) — that wipes the DB. A plain down/up keeps it (see Data persistence).
  • Fix: to have rows survive a full reset (or reach teammates), bake them into cmd/devseed or a migration.

Reset everything from scratch (clean slate)

docker compose down -v # stop + delete ALL volumes (db + Go caches)
docker compose up --build # fresh: re-pull, re-migrate, re-seed (slow, one-time)

6. Frontend / browser video

node-ip is the IP livekit advertises for WebRTC media (ICE) — it must be an address the client can reach. A browser/Electron app on the Windows host reaches the WSL IP, not 127.0.0.1. So livekit auto-detects the WSL IP at startup (from the default route, inside the host-networked container) — nothing to set, and it re-detects if your WSL IP changes. To pin a specific IP, set LIVEKIT_NODE_IP=<ip> in dev/.env (create the file) and docker compose up -d livekit.

Note: the serverUrl the client connects to (ws://127.0.0.1:7880) is the signaling URL and is independent of node-ip — leave it as-is.


7. Host networking explained

Most services in this stack use network_mode: host. That means a container shares the host's network namespace instead of getting its own isolated one: the container's 127.0.0.1 is the host's 127.0.0.1, services find each other on localhost:<port> with no port publishing, and a process that binds :7880 is listening directly on the host's :7880.

The whole question is therefore: what is "the host"?

Why WSL2 (and why Docker Desktop is awkward)

  • WSL2 / native Linux (recommended): "the host" is your WSL2 Linux distro. Host mode attaches the containers to that network, so they all talk over a shared 127.0.0.1 exactly as designed. From Windows you reach them via localhost because WSL2 forwards it. The only browser-specific tweak is LIVEKIT_NODE_IP (see §6).

  • Docker Desktop (Windows or Mac): the Docker engine runs inside a hidden Linux VM, so "the host" is that VM, not your OS. The containers still talk to each other inside the VM, but host-mode ports skip the publishing bridge that maps the VM to your OS — so reaching them from Windows/macOS depends on enabling Docker Desktop host networking, and the UDP/WebRTC media path to a browser breaks regardless: the IP LiveKit advertises is the VM's internal IP, which a real browser can't reach. On Windows this is moot: Docker Desktop uses a WSL2 VM under the hood anyway, so just install WSL2 and run there.

Why host mode at all — it's the WebRTC media, not simple TCP

Plain TCP services (Postgres, Redis) don't need host mode — they're already bridged on remapped ports. Host mode is there for LiveKit + egress, because WebRTC media is UDP and uses a wide, dynamic range of ICE ports, not one fixed port. Host mode sidesteps publishing that whole range. There are two WebRTC paths:

  1. egress ↔ livekit (internal): egress records by joining the room as a hidden WebRTC participant and pulling media over UDP. On a shared host (or a shared Docker network) this just works.
  2. browser ↔ livekit (external): the real browser exchanges UDP media with LiveKit. This is the hard one — the media must reach a host the browser can actually route to.

So even if you replaced host mode with a fixed, published UDP port range in bridge mode, the internal path would be fine but the external browser path still breaks on Docker Desktop: the UDP media has to cross host → hidden-VM → container NAT, which is exactly where it falls apart. On WSL2 the host is real Linux with a browser-reachable IP, so it works. That's why the stack runs on WSL2 — not for any cloud dependency, purely for browser-reachable WebRTC/UDP media.