Skip to content
Configuration

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 relay

Server

VariableDefaultDescription
RELAY_ADDR:4433Bind 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_FILEcerts/server.crt / certs/server.keyTLS 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 relay

Node identity

VariableDefaultDescription
RELAY_NAMErelay-<hostname>Human-readable node identifier.

Static peers

VariableDefaultDescription
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.

VariableDefaultDescription
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_FILE alone: 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_FILE and 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.

See Deployment → TLS & mTLS.

Graceful migration / GOAWAY (optional)

VariableDefaultDescription
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

VariableDefaultDescription
GROUP_CACHE_SIZE8Completed 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_CAPACITY1500Frame buffer capacity in bytes (roughly one network MTU).
RELAY_UDP_RCVBUF262144UDP 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, rtsp and seed-moq.
  • Sessions a command dials take them too, for a moqt:// URL (native QUIC) and for an https:// one (WebTransport): the relay’s peers, the funnel’s and the HLS egress’s RELAY_URL, and loadgen.
  • The keep-alive period and the idle timeout default to 10s and 60s in the relay. In the other commands they are quic-go’s: no keep-alive, and 30s. In loadgen, --keepalive and --idle-timeout set 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.
VariableDefaultDescriptionWebSocket sessions
QUIC_INITIAL_PACKET_SIZE1280 (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_PMTUDonA 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_KEEPALIVE10s in the relay, or half the idle timeout if lessHow 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_TIMEOUT60s in the relayA 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_TIMEOUT5s (quic-go)A handshake silent for this long is given up, 1ms or more.Yes
QUIC_STREAM_RECEIVE_WINDOW524288 (quic-go)Bytes a peer may send on one stream before the relay has read any.Yes
QUIC_MAX_STREAM_RECEIVE_WINDOW6291456 (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_WINDOW786432 (quic-go)Bytes a peer may send on a whole connection before the relay has read any.Yes
QUIC_MAX_CONNECTION_RECEIVE_WINDOW15728640 (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

VariableDefaultDescription
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

VariableDefaultDescription
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/….

VariableDefaultDescription
WS_PORTRELAY_ADDR’s portThe 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_ENABLE10 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.

VariableDefaultDescription
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"]}]}
  • prefix confines 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 a publish scope on every track of a broadcast, which a path_auth pub amounts to, or a post scope 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 whose post scope 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 in pause makes 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.
  • reval is refused. CAT’s moqt-reval asks 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 publish scope of the token matches their broadcast on every track.
  • Subscriptions are served only if a subscribe scope 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 reason of its usage record: refused (its key left the set), closed (the client or relay closed it normally), dropped (the connection was lost) or upgrade_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.