Files
Piik/docs/deployment.md
T

389 lines
20 KiB
Markdown

# Minimal Deployment
Last verified against upstream documentation: 2026-08-19.
This section documents the repository's UDP-only deployment candidate: one
Node.js process provides the built Web client, room API, and WebSocket signaling
behind Caddy or nginx; coturn runs in `stun-only` mode for address discovery;
LiveKit supplies bounded SFU-root capacity. Normal media remains distributed
through direct or peer edges whenever those paths work.
A deployment may additionally provide one single-node LiveKit process as the
current controller's automatic final media fallback. This capacity is dormant unless the complete
`LIVEKIT_URL`/key/secret tuple is configured. It does not replace the P2P path,
the peer-assisted experiment, or required STUN discovery. It is configured with
ICE/UDP only; the candidate has no ordinary TURN or media-TCP configuration.
Production `769de201f7cc` is still the old TURN-required release. Keep it on a
separate rollback instance while validating this candidate; do not reuse its
environment or coturn relay config. Every new candidate deployment must run the
direct, SFU/UDP, UDP-blocked failure, and mixed-network procedures below.
## Topology and prerequisites
Use two DNS names, for example `share.example.com` and `stun.example.com`. They
may resolve to the same public IP: the Web ingress owns TCP 443 while the
self-hosted STUN listener owns UDP 3478.
```text
browser -- HTTPS/WSS --> Caddy or nginx :443 --> Node.js :8787
browser <------------ DTLS-SRTP P2P ------------> browser
browser -- STUN binding/UDP --> coturn stun-only :3478
browser <---------- DTLS-SRTP/UDP ----------> LiveKit :7882
```
The minimum runtime is Node.js 24 LTS and coturn 4.17.2 or a newer patched
release. Provision a valid TLS certificate for the Web name. Enable operating system time
synchronization and keep the Node application port reachable only from its
reverse proxy. A single Node process is intentional. Active participants and
signaling remain in memory; an optional protected SQLite file can restore room
identity after a restart, but it does not restore live WebRTC connections.
Build and validate the exact revision before starting it:
```sh
npm ci
npm run check
npm start
```
Run `npm start` under a service supervisor that injects the environment, restarts
on failure, and applies bounded logs. For a simple untracked environment file,
the equivalent direct launch is
`node --env-file=.env.production dist/server/server/index.js`. Do not expose port
8787 to the Internet. The application default is `0.0.0.0` for LAN development
and container compatibility, while the bare-metal production baseline below
explicitly sets `LISTEN_HOST=127.0.0.1` because only the same-host reverse proxy
should connect. A container can instead use `0.0.0.0` and enforce the intended
boundary with port publishing rules or a host firewall.
The optional same-host LiveKit layout needs no additional public hostname:
`LIVEKIT_URL=wss://share.example.com` and nginx proxies only current `/rtc/v1*`
requests to LiveKit. Retired `/rtc` and `/rtc/validate` endpoints return 404
without request-target logging. LiveKit's HTTP/WebSocket listener on TCP 7880 is
private to nginx, while WebRTC media reaches LiveKit directly on UDP 7882.
## Candidate boundary and rollback
The accepted target for `share.bonfire.icu` remains distributed and automatic:
direct/peer UDP first, then an SFU virtual parent feeding normally one or two
roots, whose peer descendants continue carrying media. Multiple exceptional viewers that cannot attach behind a healthy
root may consume additional server egress only under a separate explicit cap.
The repository candidate requires STUN but has no TURN URL/secret/TTL variables,
credential issuer, expiry refresh protocol, LiveKit ICE/TCP listener, or
embedded/external LiveKit TURN. Ordinary authenticated ICE contains only STUN
servers. LiveKit participants receive only revision-bound `sfu-config` URL/token
messages and negotiate within LiveKit's separate ICE domain.
`PEER_ASSISTED_ROOM_IDS` remains an exact-room topology boundary, but ordinary
STUN-only ICE is process-wide. An unlisted room keeps ordinary P2P routing, not
the old TURN wire. Run candidate rooms on an isolated instance/hostname and keep
the old release unchanged for rollback. If the candidate fails, roll back the
release or instance; do not add a permanent dual-transport branch.
A shared-public-IP instance can test candidate behavior while the old TURN
service stays live, but it cannot prove the clean-port boundary and must not
approve migration. Prefer a separate VM/public IP for the full gate. Before
starting, require candidate DNS/TLS, host and provider-firewall access,
independent LiveKit secrets, verified UDP 7882 and resource state, and
representative external networks and devices.
Optional selected-edge TURN is not supported by this candidate. If later network
evidence justifies it, configuration, generation-bound grants, client wire,
tests, and deployment must arrive together in a separate PR. HTTPS/WSS always
remains TLS/TCP independently.
## Production application environment
Keep the real values in the process secret store or an untracked, access-restricted
environment file. Node.js 24 can load such a file with `--env-file`; the tracked
`.env.example` is only a schema. Generate independent secrets with a password
manager or a cross-platform Node command such as:
```sh
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
```
Use this production baseline:
```dotenv
NODE_ENV=production
LISTEN_HOST=127.0.0.1
PORT=8787
PUBLIC_BASE_URL=https://share.example.com
ALLOWED_ORIGINS=https://share.example.com
ACCESS_PASSWORD=<WHOLE_SITE_PASSWORD>
ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite
ROOM_TTL_SECONDS=14400
MAX_ROOMS=1000
MAX_VIEWERS_PER_ROOM=8
STUN_URLS=stun:stun.example.com:3478
```
To make automatic SFU fallback capacity available, add the complete tuple:
```dotenv
PEER_ASSISTED_MEDIA=true
PEER_ASSISTED_ROOM_IDS=1
LIVEKIT_URL=wss://share.example.com
LIVEKIT_API_KEY=<GENERATED_LIVEKIT_API_KEY>
LIVEKIT_API_SECRET=<INDEPENDENT_SECRET_OF_AT_LEAST_32_BYTES>
MAX_SFU_ROOTS_PER_ROOM=2
```
`PEER_ASSISTED_ROOM_IDS` is the required deployment canary boundary whenever
`PEER_ASSISTED_MEDIA=true`. Its non-empty value
is a comma-separated set of exact positive numeric room IDs, using the same
1-to-12-digit syntax as room authentication. Duplicate IDs, empty entries,
leading zeroes, and malformed IDs fail startup. A non-empty allowlist requires
`PEER_ASSISTED_MEDIA=true`. Only listed rooms receive peer-assisted
authentication, routing, room quality state, or optional LiveKit fallback;
every other room keeps ordinary P2P route fields and signaling behavior. The
ordinary STUN-only ICE snapshot is process-wide; unlisted rooms do not retain
the old TURN contract. Omitting or blanking the variable fails startup rather
than enabling every room. There is no browser control, percentage rollout, or
all-room fail-open.
`ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`.
`ACCESS_PASSWORD` is optional: omit it or leave it empty for a public site. A
non-empty value must contain 1 through 128 visible ASCII characters (`0x21`
through `0x7e`). For an Internet deployment intended to stay private, use an
independent value that is not reused elsewhere.
`ROOM_DATABASE_PATH` is optional but requires `ACCESS_PASSWORD`; startup fails
if a database path is supplied without the whole-site gate. The baseline above
enables persistent protected rooms. Omit `ROOM_DATABASE_PATH` to keep random
temporary rooms, and omit both values for public mode. `ROOM_TTL_SECONDS` applies
only to temporary rooms.
`MAX_VIEWERS_PER_ROOM` defaults to 8 and accepts 1 through 16. It is an admission
limit, not evidence that the publisher can sustain that many streams.
The three `LIVEKIT_*` values must either all be absent or all be present, and a
complete tuple requires `PEER_ASSISTED_MEDIA=true`. An empty tuple keeps the
optional SDK and server path dormant. `LIVEKIT_URL` must be a plain `ws:` or
`wss:` origin with no `/rtc` suffix; production requires `wss:`.
`LIVEKIT_API_SECRET` must contain at least 32 bytes and must not reuse
`ACCESS_PASSWORD`. `MAX_SFU_ROOTS_PER_ROOM` defaults to
2 and accepts only 1 or 2; it is ignored when LiveKit is not configured. These
credentials authorize short-lived LiveKit room tokens and do not provide E2EE:
the LiveKit operator can access ordinary SFU media.
For the first candidate canary, use an isolated instance and a protected
persistent room whose ID is stable across restarts. Set only that ID, restart
the application, and verify
that its authenticated message contains `mediaMode: "peer-assisted"` while a
second non-allowlisted room contains none of `mediaMode`, `qualitySettings`,
`routeRevision`, `routeAssignment`, or `sfuStandbyUrl`. Exercise join, offer and
answer, stop, reconnect, and room deletion in both rooms. Both rooms receive
STUN-only ordinary ICE. Roll back by directing traffic to the unchanged old
release; disabling `PEER_ASSISTED_MEDIA` only disables topology/SFU routing and
does not restore the removed TURN wire. After acceptance, retire or replace the
temporary exact-room gate in a separate coherent change; never clear the value
to trigger an implicit all-room rollout.
When `ACCESS_PASSWORD` is configured, both host and viewer routes first show the
same login gate. Only `POST /api/session` accepts the password in an
`Authorization: Bearer` header, then returns a 12-hour stateless HMAC-SHA256
cookie with `HttpOnly`, `SameSite=Strict`, `Path=/`, a
bounded `Max-Age`, and, under production HTTPS, `Secure` plus an `__Host-` name.
The cookie contains no account or server-side session identifier. There is no
account database, JWT, session map, or logout endpoint; expiry or clearing site
cookies ends access. The optional room database is unrelated to access sessions.
`POST /api/rooms` and the `/signal` WebSocket upgrade use that cookie when the
gate is enabled. The room API does not accept a direct Bearer credential as an
alternate creation path. Viewers open `/r/{code}` or enter only the numeric code
at `/join`; there is no viewer token or URL fragment, while the host token remains
internal to the host page.
Room allocation has two deliberately small policies:
- With `ROOM_DATABASE_PATH`, SQLite allocates positive decimal IDs starting at
`1`. These protected rooms and links do not expire. An explicit stop or capture
track ending stops the current publication and leaves viewers waiting; it does
not delete the room. A brief signaling disconnect does not stop otherwise
healthy P2P media.
- Without `ROOM_DATABASE_PATH`, rooms use random numeric IDs and expire according
to `ROOM_TTL_SECONDS`. Stopping sharing does not immediately delete them. Public
mode always uses this policy, so anonymous clients cannot simply walk a
sequential namespace.
The built-in `node:sqlite` database stores only each room ID (the table rowid)
and SHA-256 host-token digest. It must not contain plaintext tokens, passwords,
access cookies, SDP, ICE candidates, IP addresses, viewer state, TURN
credentials, or media. Protect and back up the file as service state. The tracked
systemd unit creates `/var/lib/screener` with `StateDirectory=screener` and mode
`0700`; the production path above is writable despite `ProtectSystem=strict`.
The database is designed for one application process, not shared storage across
multiple instances.
For a simple consistent backup, stop `screener.service` before copying the
SQLite file, then start it again. Restore while the service is stopped, restore
ownership to the service account and mode `0600`, run SQLite
`PRAGMA integrity_check`, and only then start the service. An online backup must
use SQLite's [backup API](https://www.sqlite.org/backup.html) or `VACUUM INTO`;
do not copy only the live main file while it may have an active journal.
Enabling persistence does not migrate rooms that existed only in memory. The
deployment restart invalidates those temporary links; the first subsequently
created persistent room receives ID `1`.
With no `ACCESS_PASSWORD`, the random room code is the sole viewing capability
and does not provide strong privacy, so public mode is suitable only when public
access is acceptable or another trusted access layer exists.
Production startup requires one to eight syntactically valid `stun:` URLs
before the server listens. This validates shape only; it does not prove DNS,
firewall, NAT mappings, or external STUN reachability. The current application
rejects `TURN_URLS`, `TURN_SHARED_SECRET`, or `TURN_CREDENTIAL_TTL_SECONDS` when
any key is present, including with an empty value. Remove those obsolete keys
from the candidate environment; startup fails instead of pretending they enable
compatibility.
## HTTPS and WSS ingress
Terminate public TLS at Caddy or nginx and proxy every application path to the
single Node process. `/signal` must preserve the WebSocket upgrade, `Host`, and
`Origin`, and its idle/read timeout must be longer than a normal sharing session.
Do not cache `/api/*` responses. Caddy's minimal same-host form is:
```caddyfile
share.example.com {
reverse_proxy 127.0.0.1:8787
}
```
An equivalent nginx location is:
```nginx
location / {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1h;
}
```
The tracked
[`share.bonfire.icu.conf.example`](../deploy/nginx/share.bonfire.icu.conf.example)
follows this simple model and listens directly on public TCP 443. It does not
need an SNI transport router. Exact `/rtc` and pinned-v0 `/rtc/validate` requests
return 404 with access and request-line error logging disabled; only `/rtc/v1*`
is proxied. Current versioned requests carry a short-lived LiveKit JWT in the
query string, so no `/rtc*` target may fall through to the site's ordinary logs.
When Certbot manages the Web certificate, install the tracked
[`reload-nginx.sh`](../deploy/certbot/reload-nginx.sh) as an executable under
`/etc/letsencrypt/renewal-hooks/deploy/`. Run `certbot renew --dry-run` after
the first certificate is installed. This hook reloads only the Web ingress.
Keep the proxy's access-log retention bounded and access controlled. Requests to
`/r/{code}` put the room code in the path, so access logs can contain room codes
as well as network metadata. They must not be treated as public artifacts; in
public mode a current code is itself the only viewing capability.
For a process-level liveness probe, send `GET /healthz`. A running process
returns HTTP 200 with `{"status":"ok"}` and `Cache-Control: no-store`; other
methods return 405 with `Allow: GET`. The endpoint does not inspect room state,
STUN/SFU reachability, or any external dependency, so use it only to decide whether
the Node process can accept HTTP requests. It is not a deployment-readiness or
end-to-end media check.
## Optional single-node LiveKit fallback
Use the tracked
[`livekit.yaml.example`](../deploy/livekit/livekit.yaml.example) and
[`livekit.service.example`](../deploy/systemd/livekit.service.example) as the
minimal single-node baseline. Install a reviewed, pinned LiveKit Server release
from its official release assets, generate an independent key pair with
`livekit-server generate-keys`, and place only the YAML key mapping in
`/etc/livekit/keys.yaml`. Keep that file owned by the `livekit` service account
with mode `0600`; put the same values in Screener's untracked process secrets.
The example deliberately omits Redis and every recording, ingress, egress,
webhook, external-TURN, and embedded-TURN service. It explicitly sets
`tcp_port: 0` and `allow_tcp_fallback: false`, and points `stun_servers` at the
deployment's self-hosted STUN listener so pinned LiveKit cannot inherit its
default public Google STUN servers. Do not add Redis for this one-node workload.
The service journal is the diagnostic log; keep its retention finite and access
restricted. Never enable debug/Pion packet logging continuously or persist JWTs,
SDP, ICE candidates, API secrets, or full `/rtc` and `/rtc/*` request targets.
Configure both the host firewall and the provider firewall independently:
| Destination | Protocol | Exposure | Purpose |
| --- | --- | --- | --- |
| `127.0.0.1:7880` | TCP | Private | nginx to LiveKit signaling/API |
| public host `:7882` | UDP | Public | WebRTC ICE/UDP mux |
LiveKit may listen beyond loopback while it discovers and advertises public
media addresses, so the public-firewall denial of TCP 7880 is mandatory; nginx
must be its only ingress. UDP 7882 is a direct media listener and must not be
placed behind the HTTP reverse proxy. If the host is behind NAT, forward it
without port translation and verify the advertised candidate from an external
network. UDP-blocked networks are expected to fail clearly in this candidate;
do not set `rtc.tcp_port` above zero, enable TCP fallback, or add TURN to make a
canary pass silently.
## Self-hosted STUN
Copy [`deploy/coturn/turnserver.conf.example`](../deploy/coturn/turnserver.conf.example)
to an untracked service-owned location and use the tracked
[`coturn.service.example`](../deploy/systemd/coturn.service.example). The config
uses coturn's documented `stun-only` mode, ignores every TURN allocation request,
and disables TCP and TLS client listeners. It has no auth secret, user database,
relay port range, certificate, quota, or TURN REST credential. Pinned coturn
4.17.2 deprecates `no-dtls`, so the tracked config does not use that obsolete
switch or claim a separate DTLS listener gate.
On a single-homed public host, automatic listener selection is sufficient. On a
multi-homed host, set `listening-ip` to the intended public interface. Open only
UDP 3478 and keep TCP 3478/5349 plus every old relay range closed. The same public
IP may serve HTTPS/WSS on TCP 443 and LiveKit media on UDP 7882.
The example writes informational logs to stdout. Apply finite supervisor
retention and restrict access because STUN sees client IP addresses. Do not
enable `verbose` or `log-binding` continuously. `stun-only` is a hard candidate
boundary, not a hidden unauthenticated TURN service.
Open only these public listeners:
| Destination | Protocol | Purpose |
| --- | --- | --- |
| `share.example.com:443` | TCP | HTTPS and WSS |
| `stun.example.com:3478` | UDP | STUN binding only |
| host public address `:7882` | UDP | LiveKit WebRTC ICE/UDP mux |
## Verification
Run these checks from real external networks before calling the deployment usable:
1. Open the official [Trickle ICE sample](https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/)
and test the configured STUN URL. It must produce an `srflx` candidate over
UDP and no `relay` candidate. Confirm TCP 3478/5349 and the old relay range
are closed externally.
2. Create a normal Screener room on two different networks. Confirm media flows
and the selected-pair stats report a non-relay path when direct ICE succeeds.
3. On an exact allowlisted room, exhaust a peer route and verify the host plus at
most two necessary roots select LiveKit UDP 7882. Peer descendants stay on
ordinary direct UDP and host/relay downstream caps remain two/one.
4. Block all UDP on one test client. Verify bounded ICE recovery ends in a clear
connection failure, without an ICE/TCP, TURN/TCP, or long pseudo-connected
path. No force-relay product branch exists in this candidate.
5. Exercise root departure, reconnect, SFU unavailable, route prepare rollback,
stop, and source/profile changes. Unaffected subtrees must not migrate.
6. Repeat at 1, 3, 5, and 8 viewers across representative consumer networks.
Record selected protocol, RTT, bitrate, frame rate, packet loss, publisher
upload/encode load, LiveKit ingress/egress, and final decoded quality. A real
1:8 session remains unverified until this matrix passes.
References: [coturn 4.17.2 release](https://github.com/coturn/coturn/releases/tag/4.17.2),
[pinned turnserver documentation](https://github.com/coturn/coturn/blob/4.17.2/README.turnserver),
the [pinned example configuration](https://github.com/coturn/coturn/blob/4.17.2/examples/etc/turnserver.conf),
[RFC6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/),
[LiveKit firewall guidance](https://docs.livekit.io/deploy/admin/firewall/), and
the [pinned LiveKit configuration sample](https://github.com/livekit/livekit/blob/v1.13.5/config-sample.yaml).