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-backendservice. Paths below are relative to thequbital-backendrepo 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
.envsecrets file from the team secret manager (see.env.examplein the repo root). - A clone of the repo on the WSL Linux filesystem (
~/…,/opt/…) — not/mnt/c/...(breaks hot-reload file-watching).
Navigation:
- Just want it running? → Configuration & secrets → 1. Install Docker → 2. Quick start → 3. Daily use.
- Want to understand how it works? → 4. How it all works · 7. Host networking explained.
- Something broke? → 5. Troubleshooting.
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, Redis6380) 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.
| What | Where |
|---|---|
| Backend API | http://localhost:3001 |
| Postgres | localhost:5433 · db postgres · user/pw postgres |
| LiveKit | ws://localhost:7880 |
| Recordings | MinIO 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.
| Command | Your data (e.g. a room you added by hand) |
|---|---|
docker compose stop / restart | ✅ kept |
docker compose down → up | ✅ 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.comwith matching roles + licenses. - It resolves each user's current
workos_idand theworkos_org_idby 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 │
└─────────────┘ └─────────────────────┘
| Service | Image | Network | Job |
|---|---|---|---|
redis | redis:7-alpine | bridge 6380→6379 | shared bus for livekit + egress |
postgres | supabase/postgres | bridge 5433→5432 | the database |
livekit | livekit/livekit-server | host | media server (--dev) |
egress | livekit/egress | host | records rooms → MinIO (S3 upload) |
api | built (Go + air) | host | the backend, port 3001, hot-reloads |
worker | built (Go + air) | host | WorkOS event-sync, hot-reloads |
minio | minio/minio | host | S3-compatible store for recordings + avatars (:9000 API, :9001 console) |
minio-setup | minio/mc | host | one-shot: creates the recordings / avatars buckets, then exits |
seed | built (Go) | host | one-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:
- redis →
redis-cli ping→ healthy. - postgres → on a fresh volume the supabase image initializes its roles /
extensions / schemas, then our
db-init.shruns last → grantspostgressuperuser + creates the realtime stubs → server accepts connections → healthy. - livekit (after redis healthy) → connects redis, advertises its node IP.
- api (after postgres healthy + livekit started) →
airbuilds & runs it → loads config → applies migrations 1→31 → listens:3001→/healthhealthy. - worker (after postgres healthy + api healthy) → builds & runs; its migration call no-ops (api already ran them) → starts the event-sync loop.
- 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:
supabase/postgresimage (not plainpostgres): the migrationsCREATE EXTENSION pg_cron / pg_net, usevault, therealtimeschema, and theanon/authenticatedroles. Plain Postgres dies on migration 8; the supabase image ships all of it.- Realtime stubs (
db-stubs.sql): even the supabase image lacksrealtime.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. postgresgranted superuser (db-init.sh): in the imagepostgresisn't a superuser and therealtimeschema is owned bysupabase_admin, so migration 11'sCREATE POLICYis denied.db-init.shruns as a Postgresinitdb.dscript, connects assupabase_admin(trusted on the local socket during init), grantspostgressuperuser, 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")
| Decision | Reason |
|---|---|
-buildvcs=false in air | go 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 healthy | api 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 tunnel | self-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 trick | the 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
| Service | Why | Effect locally |
|---|---|---|
| WorkOS | auth provider, can't self-host | required; dev-env keys live in ../.env |
| Grafana Mimir | metrics backend | metrics 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'sR2_*env is overridden tohttp://127.0.0.1:9000(minioadmin/minioadmin, bucketrecordings). - the dev stack sets
R2_FORCE_PATH_STYLE=true, so egress (internal/platform/livekit/client.go) usesS3Upload.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 usesUsePathStyle, 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
| File | Role |
|---|---|
docker-compose.yml | the whole stack: services, networking, volumes, healthchecks, ordering |
Dockerfile.backend | dev image: golang:1.25 + curl + air (source is bind-mounted, not copied) |
air.api.toml / air.worker.toml | hot-reload config (what to build, how to run) |
db-init.sh | one-time privileged DB init (superuser + stubs), runs inside postgres initdb |
db-stubs.sql | the 3 realtime objects the standalone image lacks |
livekit.yaml | LiveKit keys + webhook URL |
egress.yaml | egress → livekit/redis addresses + recording output |
.env | Compose 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 psreturns 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 -fshows 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
../.envis missing or incomplete — required vars fail at startup. - Fix: get the full
.envfrom 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 = finschema_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 -dstarts 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-ipthe client can't reach. Signaling (ws://…:7880) connects, then media ICE fails.127.0.0.1is 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>indev/.env(ip -4 addr show eth0) anddocker compose up -d livekit. Leave the client'sserverUrlasws://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../.envlacks the WorkOS key. - Postgres connection refused → migrations not finished yet; the service waits on
api: healthy, so just re-rundocker compose run --rm seed.
- Verify:
docker compose run --rm seedlogs✔ 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) anddocker 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
recordingsbucket, not a local folder — browsehttp://localhost:9001(minioadmin/minioadmin). They are not indev/.runtime/recordings/. - Diagnose:
docker compose logs egress | grep -E "request validated|egress_aborted|Start signal|uploaded" - Causes:
- 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. - Buckets missing →
docker compose up -d minio-setup(re-createsrecordings/avatars).
- Room-composite aborts with
- 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
.gofile →docker compose logs -f apishows a rebuild.
My data disappeared after a restart
- Cause: you ran
docker compose down -v(or removed the volume) — that wipes the DB. A plaindown/upkeeps it (see Data persistence). - Fix: to have rows survive a full reset (or reach teammates), bake them into
cmd/devseedor 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.1exactly as designed. From Windows you reach them vialocalhostbecause WSL2 forwards it. The only browser-specific tweak isLIVEKIT_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:
- 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.
- 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.