Run
docker compose up locally. Same images in production.
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
docker compose upOpen 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.
docker compose run --rm --no-deps \
-v "$PWD:/out" rill \
init --domain rill.example.com --ip 203.0.113.10 --out /outWrites 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:
docker compose up -dImages: 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.
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 --buildDNS 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 |
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 |