# Self-Hosting Guide > Deploying to a fresh VPS? Follow the step-by-step command sequence in > [production-deploy.md](production-deploy.md) instead. Target deployment: a single VPS (2 vCPU / 4 GB) serving up to **30 concurrent 1-on-1 rooms** (60 peers, 120 tracks). ## 1. Prerequisites - Docker + Docker Compose (or Rust 1.85+ and PostgreSQL 15+ for bare metal) - A DNS **A record** pointing `videyo.co.ke` at the VPS public IP - Open ports: `80/tcp` + `443/tcp` (nginx), `3478/tcp+udp` and `49152–49252/udp` (TURN), `10000–10100/udp` (SFU media) TLS certificates are issued automatically by Let's Encrypt on first deploy (step 3); no manual certificate setup is needed. ## 2. Configure ```bash cp .env.example .env # edit .env: # PUBLIC_IP – the VPS public IP (SFU advertises it in ICE candidates) # DB_PASSWORD – strong random value # TURN_SECRET – strong random value # GRAFANA_ADMIN_PASSWORD, ADMIN_API_KEY_SECRET – strong random values # LETSENCRYPT_EMAIL – receives certificate expiry notices ``` The ES256 JWT key pair is generated by the API into the shared `jwt_keys` Docker volume on first start; nothing to prepare. ## 3. Run ```bash # one-time: issue the Let's Encrypt certificate (starts nginx, needs port 80 open) LETSENCRYPT_EMAIL=you@example.com ./scripts/init-letsencrypt.sh # then build and start the whole stack docker compose up -d --build docker compose logs -f api sfu ``` The `certbot` container renews the certificate automatically every 12 h. nginx only picks up a renewed certificate after a reload, so add a weekly host cron entry, e.g.: ```cron 17 3 * * 1 cd /path/to/videochat && docker compose exec nginx nginx -s reload ``` On first start the API generates the ES256 JWT key pair into `keys/`, runs all migrations, performs **startup crash-recovery cleanup** (closes rooms left `open` and participants left hanging by any previous crash — see the `startup cleanup complete` log line), then starts the hourly usage rollup and monthly invoice tasks. > **Note:** the SFU container needs the generated `keys/jwt_public.pem` (shared via the `jwt_keys` volume) — start the API first so the key pair exists. ## 4. Bootstrap the first admin With the stack running, insert the first tenant + admin API key directly (one-time): ```sql -- psql "$DATABASE_URL" -- pick your own secret; store only its hash: WITH t AS ( INSERT INTO tenants (name, billing_email, plan_tier) VALUES ('My Org', 'billing@example.com', 'pro') RETURNING id ) INSERT INTO api_keys (tenant_id, key_hash, key_prefix, scope) SELECT id, '', '', 'admin' FROM t; ``` Everything after that (tenants, keys, rooms, billing) goes through the HTTP API — see [api-reference.md](api-reference.md). ## 5. Configuration reference ### API (`api/`, port 3000) | Variable | Default | Meaning | |----------|---------|---------| | `DATABASE_URL` | — | PostgreSQL connection string | | `JWT_PRIVATE_KEY_PATH` / `JWT_PUBLIC_KEY_PATH` | `keys/jwt_private.pem` / `…public.pem` | ES256 key pair (auto-generated if missing) | | `API_BIND_ADDRESS` | `0.0.0.0:3000` | Listen address | | `SFU_WS_URL` | `ws://localhost:8080/signal` | Advertised to clients in token responses | | `TOKEN_TTL_SECONDS` | `3600` | Join-token lifetime | | `ROLLUP_INTERVAL_SECONDS` | `3600` | Usage rollup cadence | | `RATE_LIMIT_RPS` / `RATE_LIMIT_BURST` | `10` / `20` | Per-API-key token bucket | | `BILLING_CURRENCY` | `USD` | Currency code stamped on invoices | | `INVOICE_GENERATION_DAY` | `1` | Day of month for auto draft invoices | | `AUTO_INVOICE_ENABLED` | `true` | Monthly draft invoice generation | | `INVOICE_CHECK_INTERVAL_SECONDS` | `3600` | Invoice-task wake-up interval | | `RUST_LOG` | `info` | Log filter (structured JSON logs to stdout) | ### SFU (`sfu/`, port 8080) | Variable | Default | Meaning | |----------|---------|---------| | `SIGNAL_PORT` | `8080` | Signaling HTTP/WebSocket port | | `RTP_PORT_MIN` / `RTP_PORT_MAX` | `10000` / `10100` | UDP media port range (must be published) | | `PUBLIC_IP` | — | Public IP advertised in ICE host candidates; required behind NAT / Docker port mapping | | `JWT_PUBLIC_KEY_PATH` | — | Must match the API's public key | | `DATABASE_URL` | — | Metering event sink | | `CONTROL_PLANE_URL` | `http://localhost:3000` | Token-revocation feed | | `REVOCATION_POLL_INTERVAL_SECONDS` | `5` | Revocation SLA | | `METERING_BATCH_SIZE` / `METERING_FLUSH_INTERVAL_SECONDS` | `100` / `5` | Metering batcher tuning | | `TURN_URI` / `TURN_USERNAME` / `TURN_PASSWORD` / `STUN_URI` | see `sfu/src/config.rs` | NAT traversal | ## 6. Capacity, limits, and degradation - **30 open rooms** platform-wide: `POST /rooms` → `503 capacity_exceeded` + `Retry-After: 120`; new WebSocket joins for a *new* room → 503 (joining an existing room always works). - **120 tracks** on the SFU: additional publishes are refused with an in-band error message. - **Rate limiting**: 10 req/s + burst 20 per API key → `429 rate_limited` + `Retry-After`. - `/health` on both services returns `503` (degraded) when CPU > 90 %, memory > 85 %, or the SFU is at its room/track ceiling — wire this into your load balancer / uptime checks. ## 7. Monitoring - Prometheus: scrape `api:3000/metrics` and `sfu:8080/metrics` — a ready-made scrape config is in [`monitoring/prometheus.yml`](../monitoring/prometheus.yml). - Grafana: import [`monitoring/grafana-dashboard.json`](../monitoring/grafana-dashboard.json) (rooms/peers/tracks, request rate, p95 latency, rate-limit and token counters). - Logs: both services emit structured JSON (`RUST_LOG` filter); point your log collector at container stdout. ## 8. Crash recovery - Both services are stateless except PostgreSQL; the SFU holds call state in memory only. - If the SFU crashes, `restart: unless-stopped` brings it back; clients must re-join (SDK: re-issue token + reconnect). - On every API start, orphaned `open` rooms are closed and hanging participants marked as left, so capacity accounting and usage rollups stay correct after any crash. This also runs on API-only restarts — a room closed while its SFU call is still live keeps streaming (metering is unaffected); the row merely frees capacity. ## 9. Load testing Verify the deployment against the Phase 4 acceptance target (30 rooms × 2 peers for 1 hour): ```bash # with the stack up and a static server for the repo root: python3 -m http.server 8765 --directory . & LOAD_ROOMS=30 LOAD_DURATION_MINUTES=60 node e2e/load_test.js ``` The script creates the rooms, drives 60 headless-Chrome participants, samples `/health` every 15 s, and prints a PASS/FAIL report with peak CPU/memory/track figures. Default run is 5 minutes; set `LOAD_DURATION_MINUTES=60` for the full acceptance run. ## 10. Backups Only PostgreSQL matters. `pg_dump` nightly is sufficient; metering events are re-aggregable from `metering_events`, and invoices snapshot their own line items.