---
url: https://doc.moq.dev/bin/relay/config.md
description: TOML reference for moq-relay
---

# Configuration

`moq-relay relay.toml`. Every key is also a CLI flag and environment variable.
Most names join the section and key: `listen.tls.cert` is
`--listen-tls-cert` / `MOQ_LISTEN_TLS_CERT`. The `listen.bind` key deliberately
uses the shorter `--listen` / `MOQ_LISTEN` spelling.
Precedence is CLI > env > file > defaults: a flag or environment variable that
was actually supplied overrides the file, and a file key that was actually
written (an empty list, a `false` boolean) overrides the built-in default.

## \[listen]

```toml
[listen]
bind = "[::]:443"                    # QUIC (UDP), as --listen. Omit for a stream-only relay.
version = ["moq-lite-05"]            # Restrict accepted versions. Omit for all.
timeout = "10s"                      # Handshake deadline. "0" waits forever.

[listen.tls]
cert = "cert.pem"                    # Certificate chain and key. Reloaded on change.
key = "key.pem"
generate = ["localhost"]             # Or: a self-signed cert for development.
root = ["peer-ca.pem"]               # Optional: CAs for client certs (mTLS), reported to the auth server.

[listen.tcp]                         # Plaintext qmux over TCP for trusted local workers.
bind = "127.0.0.1:4444"

[listen.unix]                        # Plaintext qmux over a Unix socket, gated by peer credentials.
bind = "/run/moq/internal.sock"
allow.uid = [1001]
```

`timeout` bounds how long an accepted connection has to finish its handshake:
the QUIC, WebTransport, WebSocket, or qmux one, then the MoQ SETUP, through the
relay accepting the session. After that it is an ordinary session. A peer that
connects and never speaks is closed instead of being held open by keep-alives,
with the MoQ timeout code once its transport is up. The `[web]` listeners apply it to reading HTTP request
headers and, for the WebSocket fallback, to the SETUP after the upgrade. The
`io_uring` workers do not apply it yet.

## \[quic]

Transport tuning, applied to accepted and dialed connections alike.

```toml
[quic]
congestion_control = "delay"         # "delay" (BBR, the default) or "loss" (CUBIC).
max_streams = 10000                  # Concurrent streams per connection, bidi and uni. Default.
idle_timeout = "10s"                 # Drop a connection after this long with nothing on it. Default.
keep_alive = "3s"                    # Ping interval; "0s" disables it. Ignored by iroh. Default.
gso = true                           # UDP segmentation offload. iroh cannot turn it off.
mtu_discovery = false                # Path MTU discovery. Default.
receive_window = 67108864            # Whole-connection window, in bytes. Default (64 MiB).
stream_receive_window = 8388608      # Per-stream window. Omit for the backend default.
send_window = 33554432               # Unacknowledged outgoing data. Omit for the backend default.
qlog = "/var/log/moq/qlog"           # Existing directory. Needs the `qlog` build feature.
```

The native QUIC stack uses BBRv3 for delay-based congestion control. Iroh also
uses noq and the same congestion controller.

`idle_timeout` is how long a peer that vanished without a close keeps its
sessions, and so its [cluster routes](/bin/relay/cluster#failure-detection).
QUIC uses the smaller of the two endpoints' values
([RFC 9000 section 10.1](https://www.rfc-editor.org/rfc/rfc9000#section-10.1)),
so this also bounds the clients and peers on the other end. Keep `keep_alive`
under a third of it, so a quiet connection that loses one ping still pings
again before the deadline.

Raise the receive windows when a fat, long path idles below the link rate: a
window under the bandwidth-delay product stalls the sender waiting for credit.
`receive_window` bounds how much unread data one peer can make the relay buffer
across all of its streams; the transport's own default is unlimited, so it
defaults to 64 MiB, enough for a relay-to-relay session to carry several Gbps at
a 100 ms RTT.
Keep `stream_receive_window` well under `receive_window` so one slow group
cannot starve the connection. `send_window` caps unacknowledged outgoing data
whatever the peer allows, bounding the transport send buffer. A zero window is refused, and the receive windows must fit a QUIC
varint since they ride on the wire as transport parameters.

## \[runtime]

By default one work-stealing runtime serves every connection off one UDP
socket. On Linux, QUIC can instead run on pinned single-threaded workers, each
with its own socket on the listen address (`SO_REUSEPORT`), so a connection is
handled start to finish by one thread and its packets never cross cores.

```toml
[runtime]
workers = 8                          # Single-threaded QUIC workers. Omit for the shared runtime.
pin = true                           # Pin each worker to a core. Default.
io_uring = false                     # Drive them with io_uring instead of tokio.
```

Packets are steered by connection ID, so a client that migrates stays with its
worker. The group shares one port, including an ephemeral (zero) port, which is
resolved once and joined by every worker. Use an explicit port unless
something reads the bound address at startup. `workers` needs the `noq`
feature and real certificate files rather than `tls.generate`. A build without
QUIC rejects `workers` instead of
ignoring it. An embedding process leaves this group inside `Relay::run`;
taking the sockets out and driving them yourself is how a later library
update can drop QUIC while still compiling. `io_uring` additionally needs Linux 6.12+, the `io-uring` cargo
feature, and exactly one certificate read at startup; it serves moq-lite only,
and refuses to start anywhere it cannot deliver. `[quic]` applies either way,
except that `mtu_discovery` (its datagram path sends a fixed payload) and the
three flow-control windows (these workers run fixed ones) are refused under
`io_uring` rather than quietly ignored. Each worker reports its own counters at
[`/metrics`](/bin/relay/http#get-metrics). The kernel charges each worker's
ring (~56 KiB, plus a page per socket) to `RLIMIT_MEMLOCK`, a budget shared by
every io\_uring the user runs; raise it (`LimitMEMLOCK=` under systemd) if
workers fail to start with a message naming that limit.

## \[web]

```toml
[web.http]
listen = "[::]:4443"                 # HTTP: fingerprint, announced, fetch, health.

[web.https]
listen = "[::]:443"                  # HTTPS plus the WebSocket fallback.
cert = "cert.pem"
key = "key.pem"

[internal]
listen = "127.0.0.1:9101"            # Unauthenticated /health, /metrics, /nodes, /sessions, POST /sessions/revalidate. Keep private.
```

See [HTTP endpoints](/bin/relay/http).

## \[auth]

```toml
[auth]
# Exactly one of these:
url = "http://127.0.0.1:4440/"       # An auth server asked once per session event (`moq auth serve`,
                                     # or your own). https:// presents connect.tls; unix:// is a socket.
# public = "anon/**"                 # Or a static anonymous grant rooted at /, publish and subscribe alike.
# public_subscribe = ["anon/**", "demo/**"]   # Or split them; patterns, `foo/**` for a subtree.
# public_publish = ["anon/**"]
```

See [Authentication](/bin/relay/auth).

## \[cluster]

```toml
[cluster]
connect = ["https://us-east.example.com/?cost=10"]   # Peers to dial. ?cost prices the link, or use {url, cost, egress, token} objects.
node = "https://us-west.example.com/"                 # This relay's own URL.
connect_api = "https://api.example.com/peers"        # Or fetch the peer list (JSON array of URLs and/or objects) live.
token = "cluster.jwt"                                 # JWT for dials without an inline ?jwt=.
id = 12345                                            # Stable Hop ID across restarts.

[cluster.lan]                                         # Find peers on the LAN over mDNS.
enabled = true
# secret = "/etc/moq/cluster.key"                     # Optional: 64 hex chars, or a file holding them.
# app = "default"                                     # DNS-SD subtype; moq-cli shares this name.
```

See [Clustering](/bin/relay/cluster).

## \[connect]

Settings for outbound dials (cluster peers, auth API).

```toml
[connect]
timeout = "30s"                      # Dial plus handshake. "0" waits forever.
tls.root = ["ca.pem"]                # Trust these CAs (replaces system roots unless system_roots = true).
tls.cert = "relay.pem"               # Present a client certificate (mTLS to peers and the auth API).
tls.key = "relay.key"
goaway.redirect = "same-host"        # How far to trust a draining peer's redirect URI.
goaway.handover = "10s"              # Cap on how long the drained upstream keeps serving.
```

A draining upstream may name a replacement URI. `same-host` follows it only
onto the host we already dialed, so a peer moves us between ports and schemes;
`follow` also lets it choose the host, which means trusting it not to point us
into the local network, since a name it controls resolves wherever it likes;
`ignore` keeps the current address list. An empty URI also keeps it, including
caller-configured fallbacks; an accepted redirect replaces the list with the
peer's URI. A malformed or refused redirect ends the connection with an error
rather than redialing the old address or a fallback, and so does one leaving
the host a `tls.fingerprint` pin verifies. `handover` is a cap: a shorter deadline on
the received GOAWAY wins, a longer one does not extend it.

## \[cache]

```toml
[cache]
capacity = "8GiB"                    # Target bytes of cached groups. "75%" of memory also works.
headroom = "2GiB"                    # Or: keep this much system memory free and grow into the rest.
duration = "30s"                     # Cap how long a non-latest group is kept, whatever the publisher asked.
```

`duration` defaults to 30s and bounds memory by age, where `capacity` bounds it
by bytes. The latest group of every track is always kept. The two reclaim
differently: the byte budget is repaid as tracks write, so it caps what *active*
publishers build up, while `duration` sweeps on a wall-clock cadence, so a
publisher that stalls but stays connected still has its idle groups reclaimed
(an open one included, and a subscriber parked inside it is told rather than
waiting forever).

`headroom` starts a background task that re-samples system memory every few
seconds and resizes the pool. Embedders calling `cache::Config::init` directly
should know that the task is owned by the `cache::Pool` it resizes, not by the
`cache::Cache` struct or the `Relay`: it stops on its next tick once the last `Pool`
clone drops. Handing the `cache::Cache` to `cluster::Cluster::new` therefore moves the
task's lifetime onto the cluster, and keeping a `Pool` clone of your own keeps
the task running for as long as you hold it.

## \[stats]

```toml
[stats]
enabled = true
prefix = ".stats"                    # Broadcasts appear under <prefix>/node/<node>.
interval = 1                         # Seconds between snapshots.
node = "sjc/1"                       # Disambiguates relays sharing a cluster.
depth = 1                            # Also bucket by the first N path segments (per tenant).
linger = "5m"                        # Keep an empty group's broadcast announced this long. Default.
```

Each node publishes `publisher.json`, `subscriber.json`, and `sessions.json`
tracks (plus compressed `.json.z` twins) of cumulative counters per broadcast
and auth root, split by a **tier** label chosen by the auth server's grant or
`--cluster-tier`, which is what makes billing per customer or per region
possible. [Stats](/concept/stats) describes the paths, tracks, and encodings;
read them with the [`moq-stats`](https://docs.rs/moq-stats) crate.

## \[iroh]

```toml
[iroh]
enabled = true
secret = "./iroh-secret.key"         # Persist the key so the endpoint id survives restarts.
# disable_relay = true               # Direct addresses only. Right on a LAN, wrong on the internet.
```

See [Transport](/concept/transport#iroh-peer-to-peer-experimental).

## Shutdown

```toml
drain_timeout = "10s"                # Top-level key, as --drain-timeout / MOQ_DRAIN_TIMEOUT.
```

The first SIGTERM or SIGINT starts a drain: every session is sent a GOAWAY
asking it to reconnect, and is force-closed if it is still connected when the
window ends. A session that connects during the drain, such as a client with a
cached DNS answer, is sent a GOAWAY immediately, with only the time left in
the window. The relay exits as soon as every session has left, when the window
ends, or immediately on a second signal. `0` skips the GOAWAY and closes every
session at once.

The exit is logged with how long the drain took, as either
`drain complete: every session left` or `drain deadline force-closed sessions`
with the number `forced`. A session still in its handshake when the last one
leaves is not waited for.
Only moq-lite-04+ and moq-transport clients act on a GOAWAY; older ones are
closed when the window ends. An embedder can take over the signals and start
the drain itself; see [Embed](/bin/relay/#embed).

## \[log]

```toml
[log]
level = "info"                       # RUST_LOG overrides this.
```

At `info` the relay logs one `listening` record for the `[listen]` QUIC socket
and each public `[web]` listener as it binds. Each record carries the bound
address and a `kind` naming the listener:

```
INFO listening addr=[::]:4443 kind=quic
INFO listening addr=[::]:4443 kind=http
INFO listening addr=[::]:8443 kind=https
```

For these listeners, `addr` is the address the socket bound, not the one
configured, so a `listen` port of `0` reports the port the OS picked. That is
the only way to learn it from outside the process, and the QUIC and TCP ports
are chosen independently.
A relay with no `[listen]` UDP socket logs `listening (stream transports only)`
instead of the `quic` line.
