Configuration
Apart from --role, qumo is configured entirely through environment
variables — there is no config file. This page groups every variable by
concern.
Set them however your platform prefers — inline, an env file, systemd’s
EnvironmentFile=, or Docker’s --env-file:
RELAY_NAME=relay-tokyo qumo relay # inline
set -a && . ./relay.env && set +a # from a file you wrote
qumo relayServer
| Variable | Default | Description |
|---|---|---|
RELAY_ADDR | :4433 | Bind address (QUIC/MoQT). Dual-stack — binds both IPv4 and IPv6, so localhost works on hosts where it resolves to ::1 (e.g. Windows). Also serves HTTP health/metrics on the same port. |
CERT_FILE / KEY_FILE | certs/server.crt / certs/server.key | TLS certificate and key. Required — the relay exits at startup if it can’t load them. |
The node’s topology role is a CLI flag, not an env var. It is an
operator-facing label logged at startup for visibility only — it does not
affect which peers are dialed (see PEERS below):
qumo relay --role hub # or "edge"; omit for a standalone / flat relayNode identity
| Variable | Default | Description |
|---|---|---|
RELAY_NAME | relay-<hostname> | Human-readable node identifier. |
Static peers
| Variable | Default | Description |
|---|---|---|
PEERS | (empty) | Comma-separated relays to dial, as host:4433. Each host is resolved to all its addresses and every address is dialed, so a DNS name for a group of relays (role-hub.qumo-relay.service.consul:4433) connects to each; a relay that reaches itself that way drops that session. The node relays their announcements. Needs CA_FILE and a peer identity (below). |
There is no runtime peer-discovery service — the list is static, resolved and
dialed once at startup and re-dialed with backoff on disconnect. A relay
that starts later must dial the earlier ones; they learn of it only when
they restart. Every resolved address is dialed and retried for as long as
the relay runs, so name peers by addresses they listen on: localhost
resolves to ::1 as well, and a relay bound to 127.0.0.1 leaves that
dial warning forever. A peer that stops cleanly (SIGINT, GOAWAY) is
re-dialed at once; one that vanishes without closing is noticed after the
QUIC idle timeout, 60 s. See
Deployment → Peer topology for
how they fit together, and Deployment → Nomad
for a worked example of giving relays stable addresses on Nomad.
Peer trust (optional)
Relays authenticate each other with mutual TLS under a CA of yours, the
relay CA. A relay’s peer identity is a certificate that CA issued, separate
from the public certificate it serves browsers: different credentials, different
trust. PEERS only says whom to dial; identity comes from the certificates.
| Variable | Default | Description |
|---|---|---|
CA_FILE | (empty) | PEM certificate of the relay CA. A native-QUIC session whose client certificate it verifies, and that carries the peering name, is a relay peer: served without a credential, checked for nothing. A certificate without the name grants nothing. Unset: no session is a peer. |
PEER_CERT_FILE / PEER_KEY_FILE | (empty) | This relay’s peer identity: a certificate CA_FILE issued for the peering name, usable for client and server authentication. Set together, and only with CA_FILE. It is presented to the relays this one dials and to the relays that dial it. |
The four valid settings:
- nothing: a standalone relay;
CA_FILEalone: a session with a peer certificate from the CA is a peer (the HLS egress can connect that way), but this relay has no peer identity: it dials no peer and no relay can dial it;CA_FILEand a peer identity: this relay accepts peers but dials none;- all three with
PEERS: full peering.
The relay refuses to start with only one of the two peer files, a peer identity without CA_FILE, PEERS without all three, or a peer certificate the CA didn’t issue, that lacks the peering name, or that isn’t valid. A browser is never asked for a certificate.
Graceful migration / GOAWAY (optional)
| Variable | Default | Description |
|---|---|---|
GOAWAY_REDIRECT_URI | (empty) | Escape-hatch mobility primitive: on shutdown, redirect clients/peers to a successor relay. Route/subscription migration (make-before-break) is the primary mechanism — set this only when you also want graceful-shutdown redirects. |
Capacity
| Variable | Default | Description |
|---|---|---|
GROUP_CACHE_SIZE | 8 | Completed groups each track’s ring retains for late/backfill subscribers. Raise to absorb longer subscriber startup lag, at the cost of per-track memory. |
FRAME_CAPACITY | 1500 | Frame buffer capacity in bytes (roughly one network MTU). |
RELAY_UDP_RCVBUF | 262144 | UDP receive buffer (SO_RCVBUF) for the relay’s QUIC listener socket. Raise on deployments pushing beyond ~15K concurrent sessions in burst. Set to 0 to use the unmodified OS default. |
RELAY_GOGC | (unset) | GC target percentage. Opt-in: if unset (and GOGC is unset), the Go runtime default (100) is used. A fan-out relay’s goroutine stacks dominate RSS, so GC-scan CPU grows with session count; RELAY_GOGC=600..1600 reached ~18–20K sessions on an 8-core host. GOGC (the runtime’s own env var) always wins if set. Do not use GOMEMLIMIT for this workload — it forces constant GC and collapses throughput. |
Run qumo doctor to see the effective GC target, which input won, and why —
read-only, it changes nothing. See Observability.
QUIC transport (optional)
Every command that listens or dials over QUIC reads these: relay, funnel, hls, rtmp, rtsp, loadgen and seed-moq. Unset, each keeps the default shown.
- Listeners take all of them: the relay, the funnel’s own MoQT listener, and the MoQT origin of
rtmp,rtspandseed-moq. - Sessions a command dials take them too, for a
moqt://URL (native QUIC) and for anhttps://one (WebTransport): the relay’s peers, the funnel’s and the HLS egress’sRELAY_URL, andloadgen. - The keep-alive period and the idle timeout default to
10sand60sin the relay. In the other commands they are quic-go’s: no keep-alive, and30s. Inloadgen,--keepaliveand--idle-timeoutset them, and a flag on the command line wins over the environment. - The relay’s WebSocket sessions take some too, as the last column says.
| Variable | Default | Description | WebSocket sessions |
|---|---|---|---|
QUIC_INITIAL_PACKET_SIZE | 1280 (quic-go) | UDP payload, in bytes, a connection starts with, 1200 to 1452. With the IP and UDP headers a 1280-byte payload is a 1308-byte IPv4 packet, sent with “don’t fragment”, so it does not pass a path whose MTU is 1280: Tailscale and some other VPNs and tunnels, or an IPv6 link at the minimum MTU. The handshake then never completes. Set 1200, the smallest QUIC allows, on such a path. Path MTU discovery raises the size again once the connection is up. | No |
QUIC_PMTUD | on | A switch (1, true, yes, on, or 0, false, no, off). Off turns path MTU discovery off, which keeps packets at the initial size for the life of the connection. | No |
QUIC_KEEPALIVE | 10s in the relay, or half the idle timeout if less | How often an idle connection is kept alive: 1ms or more, and at most half of QUIC_IDLE_TIMEOUT, since quic-go pings no less often than that. 0 sends no keep-alives. | Yes. Without pings a WebSocket session has no round-trip time, and a proxy may close it while idle |
QUIC_IDLE_TIMEOUT | 60s in the relay | A connection silent for this long is closed, 1ms or more. There is no “never”: 0 is refused. A peer may announce a shorter timeout, and the shorter one applies. | Yes |
QUIC_HANDSHAKE_IDLE_TIMEOUT | 5s (quic-go) | A handshake silent for this long is given up, 1ms or more. | Yes |
QUIC_STREAM_RECEIVE_WINDOW | 524288 (quic-go) | Bytes a peer may send on one stream before the relay has read any. | Yes |
QUIC_MAX_STREAM_RECEIVE_WINDOW | 6291456 (quic-go) | Upper bound the per-stream window grows to. No less than the initial window in effect: the window starts there and never shrinks. | No |
QUIC_CONNECTION_RECEIVE_WINDOW | 786432 (quic-go) | Bytes a peer may send on a whole connection before the relay has read any. | Yes |
QUIC_MAX_CONNECTION_RECEIVE_WINDOW | 15728640 (quic-go) | Upper bound the per-connection window grows to. No less than the initial window in effect. | No |
Durations are written as Go durations (10s, 2m); windows as whole numbers of bytes, 1 or more. Space around a value is ignored.
A value outside what is described stops the command at startup with a message naming the variable, where quic-go would have clamped it, read it as its own default, or panicked on the first handshake. What quic-go adjusts while a connection runs is not checked: it never times out or pings faster than a few probe timeouts allow.
Each command logs the QUIC_* variables it found at startup, so a setting that did not reach the process shows by its absence.
The number of streams a peer may open is not a setting. The relay relies on quic-go’s limit as backpressure, and raising it removes that.
Profiling
| Variable | Default | Description |
|---|---|---|
RELAY_PPROF | (unset) | Opt-in net/http/pprof endpoints (/debug/pprof/...) alongside /metrics. Off by default — enable only on a trusted/loopback interface. |
RELAY_PPROF is checked for emptiness, not truthiness: any non-empty value
enables pprof, including RELAY_PPROF=0 and RELAY_PPROF=false. To keep it
off, leave the variable unset — do not set it to 0, which switches
profiling on and exposes heap object graphs and goroutine stacks on the
relay’s HTTP port.CORS — WebTransport origin check
| Variable | Default | Description |
|---|---|---|
CORS_ALLOWED_ORIGINS | (unset) | Origins permitted to open WebTransport sessions to qumo relay, qumo rtmp, and qumo rtsp/rtsp-push. Comma-separated. * allows any origin; same-host allows any port on the request’s own host. If unset, only same-origin and headerless (non-browser) clients are accepted. |
WebSocket fallback
WebTransport does not work on WebKit: a session stalls after about 7,600 streams or 16 MB (WebKit bug 319818), and every browser on iOS is WebKit. So the relay also takes WebSocket upgrades on its client endpoint (/) and serves them as MoQ sessions over QMux, with TLS on RELAY_ADDR’s own TCP port. It is on by default.
The application chooses the transport: @qumo/moq’s connect uses WebTransport unless it is told transport: "websocket", and does not switch by itself. For the browsers that need it:
import { connect, isWebKit } from "@qumo/moq";
const transport = isWebKit(navigator.userAgent) ? "websocket" : "webtransport";
const session = await connect("https://relay.example.com:4433/…", { transport });The WebSocket is dialed at the address connect was given: https://relay.example.com:4433/… is reached as wss://relay.example.com:4433/….
| Variable | Default | Description |
|---|---|---|
WS_PORT | RELAY_ADDR’s port | The TCP port WebSocket upgrades are taken on with TLS, on RELAY_ADDR’s host and with CERT_FILE and KEY_FILE. A port, not an address. The TLS side takes WebSocket upgrades and nothing else (anything else gets 426). 0 serves no TLS: upgrades then arrive in plain HTTP only, for a proxy or a load balancer that terminates TLS. |
WS_ENABLE | 1 | 0 takes no WebSocket upgrades at all. |
One TCP port, two protocols. RELAY_ADDR’s TCP port has always served plain HTTP: health, status and metrics. It now serves TLS as well, and tells the two apart by a connection’s first byte, so both keep working: http://relay:4433/health as before, and wss://relay:4433/… for clients. Open the port for TCP wherever it is open for UDP.
A port of its own. With WS_PORT set to another port, the TLS side moves there and RELAY_ADDR’s port is plain HTTP only. A client is then told where: @qumo/moq’s webSocketURL option. Without it, a client that asks for WebSocket finds none at the relay’s address, and fails to connect.
A WebSocket session is admitted exactly as a WebTransport one: the same paths, the same credential in the URL (wss://relay.example.com/…?jwt=…), the same grant and the same revalidation. Sessions on every transport share tracks, so a publisher on one reaches subscribers on the others.
A refused WebSocket client is told so in the session, not in the handshake. A browser hides the HTTP status of a failed WebSocket handshake from the page, which could then not tell a refusal from a relay it cannot reach. So the relay completes the upgrade and closes the session at once with the MoQ Unauthorized code and a reason: refused for a credential it does not accept, unavailable when it could not check one just now. A WebTransport client still gets 401, 403 or 503.
Behind a proxy, the remote address is the proxy’s. With TLS in front of RELAY_ADDR’s port, connect and end reports and the relay’s logs carry the proxy’s address for every WebSocket session that comes through it: the relay reads no forwarding header. A session over the relay’s own TLS has the client’s own.
CORS_ALLOWED_ORIGINS applies to WebSocket upgrades too, and there it is the only origin check: browsers do not apply CORS to WebSocket.
Over WebSocket everything shares one TCP connection. A lost segment delays every stream behind it, and there are no datagrams. A publisher that sends one group per audio frame still works, but groups that carry several frames cost less.
Session auth (optional)
A client puts its credential in the connect URL: https://relay.example.com/…?jwt=… over WebTransport, or moqt://relay.example.com/…?jwt=… over native QUIC. The relay verifies the credential itself, against a key set (QUMO_AUTH_KEYS); no other process is involved.
Without a key set, auth is off: the relay admits every session unchecked and logs a warning at startup, the right setting for local development and for a relay that is open on purpose.
| Variable | Default | Description |
|---|---|---|
QUMO_AUTH_KEYS | (unset) | The key set: a JWK Set of Ed25519 public keys, what qumo auth keygen writes. Its form says where it is: an https:// URL (or http:// on a loopback host only) is downloaded about every 30 s (±10%, so relays restarted together spread out) with If-None-Match; a path or a file:// URL is a file, re-read when it changes. Any other scheme is refused at startup. |
QUMO_AUTH_KEYS_CACHE | (unset: no cache) | A file the relay keeps the last downloaded key set in, and loads at startup, so a relay that restarts while the key-set URL can’t be reached still has its keys. For a key-set URL only. |
QUMO_RELAY_TOKEN | (unset) | Sent as a bearer token to a key-set URL and to QUMO_USAGE_URL. |
QUMO_USAGE_URL | (unset: no reports) | Where the relay reports each verified session’s usage (below). Needs a key set. |
Relay peers are never checked: sessions whose client certificate CA_FILE verifies and that carries the peering name, and peers this relay dials (PEERS). A session whose certificate CA_FILE verifies without the name is a client like any other, and needs a credential (Peer trust).
Verifying against a key set
A credential is a token your app signs with its own key, using the Go package github.com/qumo-dev/qumo/token (or qumo auth token while testing). The relay admits a session when, in order: the token’s header carries no crit, its alg is EdDSA and its kid is in the key set; the signature verifies; its claims are one of scopes and path_auth (never both), iat, nbf, exp, an optional jti, and, with scopes, an optional sub; the times hold, with 60 s leeway and a lifetime of at most an hour; and every path it grants lies within its key’s prefix. A path_auth claim, as other MoQ implementations sign it, is read as the scopes it amounts to (what a token grants). The session may then announce a broadcast a publish scope matches on every track, and subscribe to a track a subscribe scope matches, for as long as below describes.
Besides the standard JWK members, the relay reads prefix and pause on each key. A server writes them with Go’s token.MarshalKeySet (a token.Key with its Prefix and Pause) rather than by hand:
{"keys": [{"kty": "OKP", "crv": "Ed25519", "x": "…", "kid": "…", "prefix": "acme/app", "pause": ["publish"]}]}prefixconfines the key: a token signed with it may grant only paths at or beneath it."pause": ["publish"]starts no new sessions that may publish a broadcast (a credential with apublishscope on every track of a broadcast, which apath_authpubamounts to, or apostscope on every track, which may create any track of a funnel’s broadcast), for example while the key’s owner is at a limit on broadcasts. Sessions that only subscribe still start, including a viewer whosepostscope names one track (it writes into a funnel’s track, publishing no broadcast of its own), and live sessions continue: a pause stops what is new, not what is on air. To cut a key’s live sessions off, remove the key from the set."publish"is the only thing a key can pause; any other entry inpausemakes the set invalid, so a misspelled entry can’t leave a key unpaused. Member names are case-sensitive and, as JWK prescribes, a member the relay doesn’t know is ignored: a misspelled member name ("paused","Pause") pauses nothing. A key naming a member twice makes the set invalid."publish": false, the earlier spelling of"pause": ["publish"], is no longer read: like any member the relay doesn’t know, it is ignored, so write"pause": ["publish"].
Live sessions are re-checked every 30 s against the current set: a session whose key has left the set ends, with MoQ 0x2 (Unauthorized), so removing a key cuts its sessions off within about a minute at worst (one refresh plus one re-check), about 30 s on average. If the set can’t be refreshed, the relay keeps the last one and logs an error; after 6 hours without a refresh it admits no new sessions, while live ones continue. Before the first successful load it admits nothing. With QUMO_AUTH_KEYS_CACHE set, the last downloaded set is loaded at startup, and its age counts toward the 6 hours, so a restart doesn’t extend how long an old set is trusted. A key set file that can’t be read stops the relay at startup, rather than leaving it running and refusing everyone.
Credential expiry and live sessions
A token’s exp, nbf and iat are checked when a session starts: they decide whether it may start, not how long it may live. Your app can issue short-lived tokens, so a leaked one is soon useless, without its clients having to reconnect, and drop audio, every time one expires.
- Your app ends access by withholding the next token: a client whose session ends can’t start another. What a live session may do is your app’s to decide on top, for example which publishers its clients play.
- The key set ends sessions. A key that leaves the set ends every session it admitted at the next re-check, about 30 s on average.
- A token can’t be replaced in a live session. A session keeps the grant it started with, so give a client the widest grant it may need for the session’s life.
revalis refused. CAT’smoqt-revalasks for a live session to be revalidated against its token’s expiry, which this relay never does, so a token carrying it is refused like any claim the relay doesn’t know.
Usage reports
With QUMO_USAGE_URL set, the relay POSTs a JSON array of records to it every 10 s. Go’s github.com/qumo-dev/qumo/usage is the format, usage.Record, for a receiver to decode and check (Record.Validate):
[{"type": "session_open", "session_id": "…", "kid": "…", "ts": "…"},
{"type": "usage", "session_id": "…", "kid": "…",
"metrics": {"gateway.ingress_bytes": 10162, "gateway.egress_bytes": 9223}, "ts": "…"},
{"type": "session_close", "session_id": "…", "kid": "…",
"metrics": {"gateway.ingress_bytes": 10438, "gateway.egress_bytes": 9165}, "reason": "closed", "ts": "…"},
{"type": "viewers", "run": "…", "kid": "…",
"metrics": {"gateway.ingress_bytes": 120, "gateway.egress_bytes": 880412}, "ts": "…"}]Byte counts are cumulative and from the relay’s point of view (what it received is ingress), so a receiver can take a resent record without counting it twice.
Sessions that publish a broadcast are reported one by one, as the first three records: an open, usage every 30 s, and a close. A session publishes when its token lets it publish, or post, every track of a broadcast.
Every other session is a viewer, and viewers are reported together. Viewers outnumber publishers by orders of magnitude, and a record per viewer would make the receiver’s load grow with the audience. Their bytes are added up per key into one running total since the relay started, sent as a viewers record naming the key and the relay’s run. The run is new each time the relay starts, so a restart begins new totals rather than lowering old ones. There is no open or close for viewers, and a total is sent only when it has changed.
Records go out in batches of at most 500. A failed send, a 401, 403, 408, 413 or 429 included, keeps the records and retries them (logged once, then again when it recovers); only a batch the receiver can’t read (400 or 422) is dropped. When the relay shuts down, it waits up to 5 s for its last sessions to record their end before the final send.
What a session may do
- Refusal: a WebTransport client gets the HTTP status before the upgrade: 401 for a credential that can’t be accepted, 403 for a valid one that may not do what it asks, or 503 while the relay has no usable key set. A native-QUIC session is closed with
0x2(Unauthorized). - Announcements are routed only if a
publishscope of the token matches their broadcast on every track. - Subscriptions are served only if a
subscribescope of the token matches their broadcast and track. A refused subscription gets the same answer as a path that doesn’t exist (NotFound). - Expiry decides only whether a session may start (above): a live session, publisher or subscriber, is not ended by its token’s expiry.
- Why a session ended is the
reasonof its usage record:refused(its key left the set),closed(the client or relay closed it normally),dropped(the connection was lost) orupgrade_failed(a WebTransport session that was admitted, whose upgrade then failed, so it never started). - Relay peers and peers this relay dials carry no credential, so nothing above applies to them.
- Not yet enforced: which paths a session can discover (qumo-dev/qumo#450). Announce interest lists every path under the requested prefix, and a TRACK request returns a track’s publisher properties (TRACK_INFO) for any path. Both reveal path names and metadata, never media.
Metrics: qumo_relay_auth_requests_total{event,result} (admitted, refused, error, and unchecked with auth off), qumo_relay_announcements_refused_total, qumo_relay_subscribe_authorizations_total{result} (admitted, not_covered) and qumo_relay_sessions_ended_total{reason} (refused). auth_requests_total’s event is connect, revalidate or end; an end’s result is ok or error.