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 · @workos/emulate@0.3.0 · 43 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, Centrifugo). 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.

WorkOS runs locally too. The stack ships a WorkOS emulator container, and compose points the api, worker and seed at it, so you need neither internet nor a real WorkOS tenant to sign in. WORKOS_API_KEY and WORKOS_CLIENT_ID are overridden with the emulator's throwaway values — whatever the root .env holds for them is ignored by the stack.

The four redirect URIs are still read from the root .env, because they address the frontend, which this stack does not run. Without them the api fails to boot (env validation):

# qubital-backend/.env (repo root, gitignored)
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, Centrifugo → local container, and WorkOS → local emulator. 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, Centrifugo) 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, Centrifugo, the WorkOS emulator, 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
CentrifugoWebSocket ws://localhost:8000/connection/websocket · HTTP API localhost:8000
WorkOS emulatorhttp://localhost:4100 · API key sk_test_default
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|centrifugo
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 whichever WorkOS API the stack points at — no hardcoded IDs (so it never rots if the 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

The users it looks up are seeded in the WorkOS emulator, so there is no prerequisite in a real WorkOS environment.

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

This is the fastest way in, and the only one that skips the magic-auth code entirely. To exercise the real sign-in flow instead, see reading a magic-auth code.


3.2 WorkOS emulator

WorkOS is the source of truth for users and organizations, and it is a hosted service. The stack runs the vendor's own emulator, @workos/emulate, in the workos container instead, serving the WorkOS HTTP API on :4100. Sign-in, organizations, memberships and invitations all work with the network unplugged and without a real tenant.

The backend reaches it through WORKOS_ENDPOINT, an optional variable that defaults to https://api.workos.com when unset — so staging and production are unaffected, and only compose overrides it.

Seeded fixtures

dev/workos-emulate.config.yaml carries the same fixtures cmd/devseed expects: the org Qubital Development Environment and the four {admin,moderator,member,guest}@dev.com users, each with the matching role.

Their ids are pinned in that file. Emulator state lives in memory while the Postgres volume survives a restart, so generated ids would change on every boot and orphan the workos_id columns devseed already wrote.

Only those four addresses exist. Signing in with your own email returns 401 with no user found with email — the account exists in the real WorkOS tenant, which the stack no longer talks to. To add yourself, edit both the seed file and the devUsers list in cmd/devseed/main.go; they are a matched pair.

Reading a magic-auth code

In production WorkOS emails the magic-auth code. The emulator sends no email — it records the code in its event stream, so POST /auth/sign-in succeeds and then the frontend waits for a code that never arrives. Read it directly:

curl -s -H "Authorization: Bearer sk_test_default" \
'http://127.0.0.1:4100/events?events[]=magic_auth.created' \
| python3 -c "import json,sys; e=json.load(sys.stdin)['data'][0]['data']; print(e['email'], e['code'])"

Events come back newest first, hence data[0].

Working against real WorkOS

Comment out the three WORKOS_* overrides in the api service (and worker / seed if you need them) in dev/docker-compose.yml, and put your real key and client id in the root .env. With WORKOS_ENDPOINT unset the backend goes back to https://api.workos.com.

Limits

  • State is in memory. Every restart replays the seed file; anything created through the API in between is gone.
  • No emails. Magic-auth codes, invitations and password resets surface as events, not mail.
  • Not a conformance suite. It implements the documented API surface, so treat a behaviour difference against real WorkOS as an emulator bug worth reporting upstream, not as backend behaviour to code against.

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 11 services in two networking groups — nine 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 ──┤ │
│ │ │ │ │
│ │ centrifugo :8000 │ │
│ │ (ws + HTTP API) │ │
└────┼──────────────────────────────────────────┼─────────────────────┘
│ 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
centrifugocentrifugo/centrifugo:v6.9hostrealtime chat/presence broker (ws + HTTP API on :8000); PostgreSQL outbox consumer reads from private.centrifugo_outbox
workosbuilt (@workos/emulate)hostoffline WorkOS API on :4100 — see §3.2
miniominio/miniohostS3-compatible store for recordings, avatars, and chat attachments (:9000 API, :9001 console)
minio-setupminio/mchostone-shot: creates the recordings, avatars, and chat 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, centrifugo → 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.) Centrifugo uses host networking because its PostgreSQL outbox consumer connects to Postgres on 127.0.0.1:5433.
  • 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 → server accepts connections → healthy.
  3. livekit (after redis healthy) → connects redis, advertises its node IP.
  4. workos → loads workos-emulate.config.yaml, serves :4100/health healthy. The api gates on this: it fetches the JWKS it validates tokens against at boot.
  5. api (after postgres healthy + livekit started + workos healthy) → air builds & runs it → loads config → applies migrations 1→43 → listens :3001/health healthy.
  6. worker (after postgres healthy + api healthy) → builds & runs; its migration call no-ops (api already ran them) → starts the event-sync loop.
  7. egress (after redis healthy + livekit started) → idles until a recording job.
  8. centrifugo (after postgres healthy + api healthy) → the api's migrations create the outbox table first (private.centrifugo_outbox); Centrifugo then connects its PostgreSQL outbox consumer and starts polling. Serves WebSocket and HTTP API on :8000.

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

../.env APP SECRETS (WorkOS redirect URIs, 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, Centrifugo, 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 two 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. postgres granted superuser (db-init.sh): in the image postgres isn't a superuser and some migrations need superuser-owned objects (e.g. the supabase_realtime publication). db-init.sh runs as a Postgres initdb.d script, connects as supabase_admin (trusted on the local socket during init), and grants postgres superuser — automatically, only on a fresh volume. (Fine for a throwaway local DB; production is untouched.)

Note: db-stubs.sql (the file that used to create realtime.messages, realtime.send(), and realtime.topic() no-ops) was removed in this phase. The chat system no longer uses Supabase Realtime — it uses Centrifugo. The stubs are no longer needed.

What you lose locally (and it's fine for backend dev): pg_cron jobs are created but don't do anything meaningful without a real cron runner attached.

4.7 Centrifugo local config

Centrifugo is configured by dev/centrifugo.json. The dev config contains throwaway secrets that match the values the api service receives via docker-compose.yml environment variables. Do not use these values outside the local dev stack.

Key settings in the dev config:

SettingValueWhat it does
client.token.hmac_secret_keydev-centrifugo-hmac-secretMust match CENTRIFUGO_TOKEN_HMAC_SECRET in the api env
http_api.keydev-centrifugo-api-keyMust match CENTRIFUGO_API_KEY in the api env
consumers[].postgresql.dsnpostgres://postgres:postgres@127.0.0.1:5433/postgresOutbox consumer — connects directly to the local Postgres (direct/session connection required; LISTEN/NOTIFY does not fire through a transaction pooler)
consumers[].postgresql.outbox_table_nameprivate.centrifugo_outboxThe transactional outbox table (created in migration 000039, renamed in 000042)
Channel namespacesuser, org, status, typinguser/org enable history + forced recovery; org also enables presence; status and typing use the subscribe proxy

4.8 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.
centrifugo waits for api healthyThe api's migrations create private.centrifugo_outbox; Centrifugo's outbox consumer needs that table to exist before it starts polling.
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.9 Cloud dependencies & local object storage

ServiceWhyEffect locally
WorkOSauth providerruns locally — the workos emulator container replaces it (§3.2)
Grafana Mimirmetrics backendmetrics ship to the cloud

Auth runs locally: the dev stack replaces WorkOS with the vendor's emulator, so sign-in needs no internet and no real tenant. Object storage runs locally: it replaces cloud R2 with a MinIO container, so recordings, avatars, and chat attachments never leave your machine. Grafana Mimir is the only cloud dependency left, and nothing in normal development depends on it.

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.

Chat attachments → local MinIO chat bucket. The R2_CHAT_BUCKET env is set to chat; the minio-setup service creates it on startup. The bucket is private — chat objects are served exclusively through short-lived presigned URLs.

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.10 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 (grants postgres superuser), runs inside postgres initdb
centrifugo.jsonCentrifugo dev config — dev-only throwaway secrets; channel namespaces and outbox consumer settings
Dockerfile.workos-emulatedev image: node:22-alpine + the pinned @workos/emulate package (no published image exists)
workos-emulate.config.yamlWorkOS emulator fixtures — the dev users and org, with pinned ids (§3.2)
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. Ensure the five CENTRIFUGO_* vars and R2_CHAT_BUCKET are present.
  • 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 / 8000 / 4100)

  • Diagnose: ss -ltnp | grep -E ':(5433|6380|7880|3001|8000|4100)'
  • Cause: a native service (system Postgres/redis, or a leftover container) holds the port. Port 8000 is used by Centrifugo, 4100 by the WorkOS emulator.
  • Fix: stop the conflicting service, or change the host-port mapping in docker-compose.yml (e.g. 5434:5432). If you change Centrifugo's port, also update the CENTRIFUGO_API_URL and CENTRIFUGO_PUBLIC_WS_URL env vars.
  • Verify: docker compose up -d starts without the port error.

centrifugo won't start or exits immediately

  • Diagnose: docker compose logs centrifugo
  • Causes & fixes:
    • Postgres not yet healthy or private.centrifugo_outbox not created — Centrifugo depends on api: healthy; the api's migrations must run first. Let the api settle and Centrifugo will retry.
    • Config file missing / malformed — verify dev/centrifugo.json exists and is valid JSON.
    • Port 8000 already in use — see port conflict entry above.
  • Verify: wget -q -O /dev/null http://127.0.0.1:8000/health returns 200.

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 emulator's seed file and cmd/devseed have drifted apart. Compare the emails in dev/workos-emulate.config.yaml with the devUsers list in cmd/devseed/main.go; go test -tags emulator ./internal/platform/workos/ checks exactly this against a running emulator.
    • connection refused on :4100 → the workos container isn't up. docker compose up -d workos, then curl localhost:4100/health.
    • 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.

Sign-in succeeds but the magic-auth code never arrives

  • Cause: not a failure. The emulator sends no email — it records the code as an event instead.
  • Fix: read it from the emulator (§3.2), or skip the code entirely with /auth/dev-sign-in.
  • Verify: POST /auth/magic-auth/finalize with that code returns 200 and sets the session cookie.

Sign-in returns 401 no user found with email

  • Cause: only the four {admin,moderator,member,guest}@dev.com fixtures exist locally. Your own address lives in the real WorkOS tenant, which the stack does not talk to.
  • Fix: sign in as one of the four, or add yourself to both dev/workos-emulate.config.yaml and the devUsers list in cmd/devseed/main.go, then docker compose up -d --force-recreate workos && docker compose run --rm seed.
  • Verify: POST /auth/sign-in with that email returns 200 and authMethod: magicauth.

Migrations fail — cannot drop function current_user_id() because other objects depend on it

  • Diagnose: docker compose logs api | grep "cannot drop function"
  • Cause: a long-lived db-data volume. Migrations 000011/000015 used to create two policies on realtime.messages; they were later edited to stop doing so, so a fresh volume is fine — but a volume that ran the originals still carries the policies, and migration 000037 can't drop the function they depend on.
  • Fix: wipe the local DB — docker compose down && docker volume rm qubital-dev_db-data && docker compose up -d. To keep the data instead, drop the two vestigial policies by hand:
    docker compose exec postgres psql -U postgres -d postgres \
    -c 'DROP POLICY IF EXISTS "Broadcast channel access" ON realtime.messages;' \
    -c 'DROP POLICY IF EXISTS "Room members can send presence broadcasts" ON realtime.messages;'
  • Verify: api becomes healthy and logs Migrations completed successfully! Current version: 43.

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/chat).
  • Verify storage works (bypasses the template): run the manual participant-egress→MinIO snippet in §4.9, 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.