URL: /docs/run

---
title: Run
description: docker compose up locally. Same images in production.
icon: zap
---

Rill is one process that copies audio and video between two browsers, plus optional recording in a second container. Local and production use the same images. Cargo and bun are for compiling this repo — not the product path.

## Local

```sh
docker compose up
```

Open [http://127.0.0.1:3000](http://127.0.0.1:3000). **Create call**, share the link, enable microphone and camera in two browsers.

That command rebuilds `rill` and `rill-web` from this tree (Docker cache if nothing changed), publishes `3000/tcp` (site), `7880/tcp` (SFU API + signaling) and `50000/udp` (media), and runs `rill --dev --bind 0.0.0.0:7880`. ICE is advertised as `127.0.0.1` so host browsers on Docker Desktop Mac can send UDP. API key `devkey`, secret `secret` — refused outside `--dev`. The site at `:3000` proxies `/v1/signal` to the SFU so the browser uses the same origin. REST (`/v1/calls`, tokens) stays on `:7880`.

The same file starts `rill-record` and Garage so you can test recording. Omit those services and calls still work. The public demo does not start recording.

Chrome and desktop Safari. iOS is not proven. CI e2e is Chromium only.

## Production

Same images. Set domain, keys, public IP, UDP **50000**, and HTTPS. Calls are in memory: a restart ends every call; your app creates calls again.

```sh
docker compose run --rm --no-deps \
  -v "$PWD:/out" rill \
  init --domain rill.example.com --ip 203.0.113.10 --out /out
```

Writes `compose.yaml`, `Caddyfile`, `.env`, coturn config. Do not commit `.env`. Generated `.env` sets `RILL_BIND=127.0.0.1:7880` so the SFU HTTP API listens on loopback; Caddy reverse-proxies only `/v1`, `/health`, and `/ready` — not `/metrics`. Scrape Prometheus from the VPS: `http://127.0.0.1:7880/metrics`. Point DNS at the VPS. Then:

```sh
docker compose up -d
```

Images: `ghcr.io/treyorr/rill` and `ghcr.io/treyorr/rill/rill-record` (set `RILL_IMAGE` / `RILL_RECORD_IMAGE` after a `v*` tag). Host network on a Linux VPS is the honest UDP path. Publishing `7880/tcp` and `50000/udp` works if `RILL_PUBLIC_IP` is the machine’s public address, not a Docker bridge.

If signaling works and video does not, the advertised ICE address is wrong or UDP 50000 is blocked.

### First-party site

Linux VPS, host network. This is `https://rill.tlo3.com` — site + SFU, no recording. `rill init` stays operator SFU (no try-it UI). Ampere (Oracle ARM) **builds from this tree** — first `docker compose build` is slow.

```sh
cp env.site.example .env
# set RILL_API_KEY, RILL_API_SECRET, RILL_PUBLIC_IP (openssl rand -hex 16 / 32)
docker compose -f compose.site.yaml up -d --build
```

DNS **A** `rill.tlo3.com` → the VPS public IPv4, DNS-only (not Cloudflare-proxied: UDP 50000 cannot go through a proxy). Open **80/tcp**, **443/tcp**, **50000/udp** on both the cloud security list and the host firewall. Optional push deploy: `.github/workflows/site.yml` when `vars.SITE_DEPLOY=true` (no SFU log dump).

Check: `curl -sf https://rill.tlo3.com/ready` and open `https://rill.tlo3.com/`. Logs: `advertised_ice_ip=` must be that public IP.

## ICE, STUN, TURN

Rill is **ICE-lite**. The SFU advertises one host candidate: `RILL_PUBLIC_IP` on UDP **50000**. Browsers send media there. `--dev` never STUNs and advertises `127.0.0.1`.

In production, set `RILL_PUBLIC_IP` to the VPS public IPv4. If it is unset, Rill STUN-discovers once at boot (`RILL_STUN_URLS`, default Google). Discovery failure refuses start — set the IP instead of debugging STUN.

**TURN** is optional, for clients that cannot send UDP to you (strict firewalls, some mobile networks). It is a small coturn sidecar, not a Rill feature. Default `rill init` includes coturn: **3478/udp** and **3478/tcp**, plus relay **49152–49251/udp**. Delete the coturn service if every client can reach UDP 50000. `/ready` stays green if coturn is down.

Do not put TURN/TLS on 443 (Caddy owns 443). UDP-blocked clients use `turn:…:3478?transport=tcp`.

## Health

| Path | Meaning |
|------|---------|
| `GET /health` | Process is up |
| `GET /ready` | Sockets are bound. During drain this becomes 503 |

```sh
curl -sf https://rill.example.com/health
curl -sf https://rill.example.com/ready
```

`/` on the SFU is 404. Humans use your app (or `apps/web` on `:3000`). `/metrics` is env-gated (`RILL_METRICS=0` disables it).

Drain: `/ready` becomes 503, no new calls, live calls continue, then callers are told the server is shutting down after `RILL_SHUTDOWN_TIMEOUT` (default 30s). Compose `stop_grace_period` is 40s.

## Env

| Variable | Role |
|----------|------|
| `RILL_API_KEY` | REST bearer |
| `RILL_API_SECRET` | Join tokens and join/leave webhooks |
| `RILL_PUBLIC_URL` | Public HTTPS origin |
| `RILL_PUBLIC_IP` | ICE address. Unset → STUN once at boot; failure refuses start |
| `RILL_STUN_URLS` | Boot-time IP discovery only. Never in `--dev` |
| `RILL_BIND` | HTTP listen (init writes `127.0.0.1:7880`) |
| `RILL_UDP_PORT` | Media (default 50000) |
| `RILL_CORS_ORIGINS` | Your widget origins |
| `RILL_WEBHOOK_URL` | Join/leave/call-ended POSTs |
| `RILL_JOIN_TIMEOUT` | Created, nobody ever joined. Default `5m` (`30m` in `--dev`) |
| `RILL_EMPTY_TIMEOUT` | Had people, now 0. Default `120s` (`30m` in `--dev`) |
| `RILL_CALL_LIFETIME` | Wall clock. Default `24h` |
| `RILL_MAX_CALLS` | In-memory cap |
| `RILL_SIGNAL_RECONNECT_GRACE` | Hold media after a signaling drop (default 15s, max 120s) |
| `RILL_TURN_URLS` / `RILL_TURN_SECRET` | Optional coturn |
| `RILL_RECORD_SOCKET` | Enable recording fork (`127.0.0.1:7420` or a unix path) |
| `RILL_METRICS` | `0` disables `/metrics` |

Occupancy 1 does not time out. `devkey` / `secret` are refused here. Bucket keys stay on `rill-record`, not this process. Process-level video/screen/audio bitrate caps are operator env knobs (`RILL_VIDEO_MAX_*`, `RILL_SCREEN_MAX_*`, `RILL_AUDIO_BITRATE`) — not a per-call API.

### Ports

| Port | Why |
|------|-----|
| 443/tcp | HTTPS + signaling (Caddy) |
| 80/tcp | Let's Encrypt |
| 50000/udp | Audio and video |
