Skip to content

Configuration Recipes

Per-feature configuration examples. Each recipe shows a short JSON snippet, the environment-variable equivalent, and when to use it. Every key here is verified against the server's config types — see the configuration reference for the full table of keys, defaults, and env spellings, and Run Modes for the matching run commands.

Environment overrides use the SIGNAL_FISH__ prefix with double underscores between nested keys (see environment variable format). JSON and environment overrides can be mixed; environment variables win.

Application ID allowlist (allowed_apps)

Require every WebSocket client to submit a configured public app ID, and set per-app accounting limits. This does not authenticate a hostile client.

JSON
{
  "security": {
    "enforce_app_id_allowlist": true,
    "cors_origins": "https://yourgame.com",
    "allowed_apps": [
      {
        "app_id": "my-game",
        "app_name": "My Awesome Game",
        "max_rooms": 100,
        "max_players_per_room": 16,
        "rate_limit_per_minute": 60,
        "max_relay_bytes": 536870912
      }
    ]
  }
}

Environment equivalent (the app list is a JSON array):

Bash
export SIGNAL_FISH__SECURITY__ENFORCE_APP_ID_ALLOWLIST=true
export SIGNAL_FISH__SECURITY__CORS_ORIGINS="https://yourgame.com"
export SIGNAL_FISH__SECURITY__ALLOWED_APPS='[
  {"app_id":"my-game","app_name":"My Awesome Game","max_rooms":100,
   "max_players_per_room":16,"rate_limit_per_minute":60,
   "max_relay_bytes":536870912}
]'

When to use: any non-local deployment. max_rooms limits an app's persisted rooms across all game names, while max_players_per_room caps both new room capacity and future admission to existing rooms. Both are enforced only when app-ID allowlisting is enabled; omit them to use server-wide admission limits. rate_limit_per_minute and max_relay_bytes are optional per-app overrides. Every field above must be greater than 0 when set — a zero value rejects every creation, join, authentication, or relayed game-data frame for that app, so startup validation refuses it (omit the field instead). See Application identification for the exact trust boundary.

Tenant connect tokens (connect_token)

Add protocol-level tenant authentication on top of the allowlist (issue #517). Your control plane signs short-lived tokens with an Ed25519 private key; the server verifies them with the matching public key. The private key never reaches the server.

JSON
{
  "security": {
    "connect_token": {
      "public_key_path": "/etc/signal-fish/connect-token-key.b64"
    }
  }
}

Environment equivalent (inline key):

Bash
export SIGNAL_FISH__SECURITY__CONNECT_TOKEN='{"public_key":"<base64 32-byte Ed25519 public key>"}'

When to use: hosted or multi-tenant deployments where a replayed public app_id is not enough. Clients send one optional connect_token field in Authenticate; failures surface as CONNECT_TOKEN_INVALID and are retryable. Absent field and absent config keep today's semantics, so this composes with existing SDKs. Set exactly one of public_key or public_key_path (the loader folds the file into the inline key; a missing file is a startup error). The key reloads on SIGHUP with the allowlist. Tokens are valid at most 300 seconds (server-enforced, plus a fixed 60-second clock-skew allowance), and replay within the window is accepted — send them only over TLS. See Application identification for the full wire contract.

To also refuse token-less handshakes (server-side tenant isolation, issue #574), add "required": true to the same block:

Bash
export SIGNAL_FISH__SECURITY__CONNECT_TOKEN='{"public_key":"<base64 32-byte Ed25519 public key>","required":true}'

A token-less Authenticate then fails with CONNECT_TOKEN_REQUIRED. Released SDKs that never send the field stop authenticating, so roll the flag out per tenant with the require_connect_token flag on an allowed_apps entry (true enforces one app, false exempts one app from the global default). A required entry with no configured key is a startup error. In open-policy mode the global knob also closes the legacy skip-Authenticate path: clients that never send Authenticate are closed with 4001 auth_timeout.

TURN/STUN

Self-hosted TURN: the server mints short-lived coturn REST credentials from a shared secret and advertises STUN servers for hole-punching. It never contacts a third-party cloud.

JSON
{
  "turn": {
    "enabled": true,
    "static_auth_secret": "",
    "urls": ["turn:turn.yourgame.com:3478"],
    "stun_urls": ["stun:stun.l.google.com:19302"],
    "credential_ttl_secs": 3600
  }
}

Environment equivalent (set the secret from the environment, never the file):

Bash
export SIGNAL_FISH__TURN__ENABLED=true
export SIGNAL_FISH__TURN__STATIC_AUTH_SECRET="$(openssl rand -hex 32)"
export SIGNAL_FISH__TURN__URLS='["turn:turn.yourgame.com:3478"]'
export SIGNAL_FISH__TURN__STUN_URLS='["stun:stun.l.google.com:19302"]'
export SIGNAL_FISH__TURN__CREDENTIAL_TTL_SECS=3600

When to use: enable TURN once you have real users behind symmetric NAT, CGNAT, or restrictive firewalls (roughly 15–20% of P2P connections). STUN alone (TURN disabled) handles the rest. The same static_auth_secret must be set on both coturn and the signaling server — it is the single source of truth both sides derive credentials from. The whole [turn] block is inert when enabled is false; only stun_urls is advertised. URLs in urls must use turn:/turns: and those in stun_urls must use stun:/stuns: (validated at startup). To depend on no third-party STUN, set "stun_urls": [].

Credential rotation. static_auth_secret is the only long-lived credential in the scheme; rotate it on a schedule and immediately on suspected exposure. coturn accepts multiple valid secrets at once, so rotation is zero-downtime: add the new secret to coturn alongside the old, flip the signaling server to the new secret and redeploy, wait at least credential_ttl_secs for old minted credentials to expire, then remove the old secret from coturn. Full procedure: rotating the shared secret.

TLS

Terminate TLS in the server itself (built-in) when you are not fronting it with a reverse proxy.

JSON
{
  "security": {
    "transport": {
      "tls": {
        "enabled": true,
        "certificate_path": "/etc/ssl/signal-fish/fullchain.pem",
        "private_key_path": "/etc/ssl/signal-fish/privkey.pem",
        "client_ca_cert_path": null,
        "client_auth": "none"
      }
    }
  }
}

Environment equivalent:

Bash
export SIGNAL_FISH__SECURITY__TRANSPORT__TLS__ENABLED=true
export SIGNAL_FISH__SECURITY__TRANSPORT__TLS__CERTIFICATE_PATH=/etc/ssl/signal-fish/fullchain.pem
export SIGNAL_FISH__SECURITY__TRANSPORT__TLS__PRIVATE_KEY_PATH=/etc/ssl/signal-fish/privkey.pem

When to use: built-in TLS suits single-node deployments or when you do not want a proxy in the path. If you already run nginx or Caddy, terminate TLS there instead (leave tls.enabled=false) — see reverse proxy setup. Either way the public signaling endpoint must be wss://, which is load-bearing for WebRTC security (see signaling must run over wss://). client_auth accepts none, optional, or require; require plus client_ca_cert_path enables mTLS.

Per-game topology mapping

Pin specific games to a topology while unmapped games use the mesh-first default.

JSON
{
  "session": {
    "default_topology": "mesh",
    "game_topology_mappings": {
      "BoardGame": "host",
      "idle-clicker": "relay"
    },
    "enable_webrtc": true,
    "enable_direct": true
  }
}

Environment equivalent (the mapping is a JSON object of game name to topology):

Bash
export SIGNAL_FISH__SESSION__DEFAULT_TOPOLOGY=mesh
export SIGNAL_FISH__SESSION__GAME_TOPOLOGY_MAPPINGS='{"BoardGame":"host","idle-clicker":"relay"}'

When to use: when different games want different transports — e.g. unmapped titles default to mesh (full WebRTC peer mesh), a turn-based title pins host, and a latency-insensitive title pins relay. Valid topologies are relay, host, and mesh. Set "default_topology": "relay" to restore the old relay-first behavior for every unmapped game. Every upgrade gracefully degrades to the relay floor, so this is always safe: if both enable_webrtc and enable_direct are false, rooms that want peer-to-peer simply fall back to relay (the server warns but still starts).

Token binding

Token-binding v2 supports HTTP/1.1 GET and HTTP/2 extended CONNECT. HTTP/1.1 clients use Sec-WebSocket-Key. HTTP/2 clients send x-signalfish-token-binding-key as described below. Required binding refuses clients that omit the subprotocol or client key.

Token binding v2 requires every JSON or binary client message to carry an HMAC under a connection key derived from both a client key and a server-fresh challenge. One sequence covers both frame formats, so a proof cannot be replayed, reordered, or moved to another connection. Fingerprint mode additionally binds proofs and reconnect credentials to the authenticated mTLS leaf certificate. It is off by default and is also summarized in the configuration reference and feature matrix.

JSON
{
  "security": {
    "transport": {
      "token_binding": {
        "enabled": true,
        "required": false,
        "require_client_fingerprint": false,
        "subprotocol": "signalfish.tokenbinding.v2",
        "scheme": "server_nonce_hkdf_sha256"
      }
    }
  }
}

Environment equivalent:

Bash
export SIGNAL_FISH__SECURITY__TRANSPORT__TOKEN_BINDING__ENABLED=true
export SIGNAL_FISH__SECURITY__TRANSPORT__TOKEN_BINDING__REQUIRED=false
export SIGNAL_FISH__SECURITY__TRANSPORT__TOKEN_BINDING__REQUIRE_CLIENT_FINGERPRINT=false

What it does and how to enable it:

  • enabled turns on negotiation of the token-binding WebSocket subprotocol (subprotocol, default signalfish.tokenbinding.v2). With enabled=true and required=false, clients that advertise the subprotocol send proofs on each JSON or binary message; clients that do not still connect normally — a safe, opt-in rollout. Custom non-reserved aliases are supported, but a name in the signalfish.tokenbinding.* namespace must be exactly signalfish.tokenbinding.v2; legacy or future reserved version names fail startup rather than relabeling the v2 wire contract.
  • required=true rejects clients that do not advertise the subprotocol and requires enabled=true. Set this only after every client in your fleet supports token binding, or you will lock out existing clients.
  • require_client_fingerprint=true binds every proof to the lowercase hexadecimal SHA-256 digest of the authenticated mTLS leaf certificate's DER bytes. It requires built-in TLS and tls.client_auth set to optional or require, plus token_binding.required=true so a client cannot opt out by omitting the subprotocol. Under optional, a WebSocket client that presents no certificate is rejected because it cannot satisfy this setting. Clients include the exact 64-character fingerprint in token_binding.fingerprint and append those ASCII bytes when computing the frame HMAC. Direct client-supplied fingerprint and forwarding headers are ignored and cannot override the rustls identity.
  • scheme selects the per-frame signing scheme (default server_nonce_hkdf_sha256). The legacy sec_websocket_key_sha256 value is still parsed to produce an actionable migration error, but an enabled server rejects it because it has no server freshness.
  • Every token-bound binary frame uses the signed MessagePack envelope described below. A raw legacy binary frame is unauthorized whether or not fingerprint binding is enabled.

Token binding requires TLS to authenticate. The connection key is derived from the client key plus a server-fresh challenge. Over plaintext ws://, both inputs are visible on the wire, so a passive observer can derive the key and forge every proof — in that deployment proofs provide replay ordering only, not authentication. Serve token-bound traffic over built-in TLS or reverse-proxy-terminated wss://, and use required=true where clients must not be able to opt out. The startup warning fires whenever binding is enabled but optional without built-in TLS — including safe reverse-proxy deployments, which the server cannot see; treat it as a reminder to verify the wire is encrypted.

v2 wire contract

The first server application message after a token-bound WebSocket upgrade is:

JSON
{"type":"TokenBindingChallenge","data":{"version":2,"scheme":"server_nonce_hkdf_sha256","nonce":"BASE64_32_BYTES","first_sequence":1}}

Select the client key for the WebSocket transport:

Transport Client key
HTTP/1.1 GET The existing Sec-WebSocket-Key header
HTTP/2 extended CONNECT The x-signalfish-token-binding-key application header

For HTTP/2, generate 16 random bytes with a cryptographic RNG for each new WebSocket stream and encode them with standard padded Base64. Send that value in x-signalfish-token-binding-key, and offer signalfish.tokenbinding.v2 in sec-websocket-protocol. Do not send Sec-WebSocket-Key. Wait for a 200 response that selects the offered subprotocol, then read the challenge. The server does not send Sec-WebSocket-Accept on this transport. A reverse proxy must preserve the application header when it forwards extended CONNECT.

The server requires exactly one client key header in total. It rejects repeated key headers, both key headers together, invalid Base64, and decoded keys that are not 16 bytes. It does not fall back to another header after invalid input. These checks apply when token binding is negotiated. Existing HTTP/1.1 clients need no changes.

Browser WebSocket APIs cannot set this application header or expose the HTTP/1.1 handshake key. Token binding requires a client that can control and read its handshake material. The shipped browser and native reference clients do not currently implement token binding.

Base64-decode the selected 16-byte client key and the challenge's 32-byte nonce. Derive a 32-byte connection key with HKDF-SHA-256 using the client key as input keying material, the nonce as the salt, and signalfish.tokenbinding.v2/session-key as the info value. Start at first_sequence and increment by exactly one after every accepted JSON or binary frame. The server rejects gaps, duplicates, and reordered sequences; a rejected proof does not advance the sequence.

The nonce is generated independently for each accepted WebSocket with the operating system's cryptographic RNG (256 bits, making accidental reuse negligible), is valid only for that connection, and is discarded when the connection closes. A reconnect is a new WebSocket and always receives a new challenge; no challenge or sequence state resumes across reconnects.

For a JSON message, remove its top-level token_binding member and encode the remaining value using RFC 8785 (JCS). This includes ECMAScript-compatible number serialization, recursive UTF-16 code-unit property ordering, no insignificant whitespace, and UTF-8 output. Duplicate members and non-finite numbers are not valid inputs. To keep the parsed application value identical across implementations, every integer anywhere in the envelope (including sequence) must be within -9007199254740991..=9007199254740991; the server rejects values outside that safe range before proof verification. Negative zero, fractional, and exponent-form numbers are rejected on token-bound connections because the server's ordinary JSON parser intentionally retains its faster non-float_roundtrip behavior; this keeps every accepted proof portable without changing non-token traffic. HMAC-SHA-256 covers, in order:

  1. the literal bytes signalfish.tokenbinding.v2\0json\0;
  2. the proof sequence as an unsigned 64-bit big-endian integer;
  3. the RFC 8785 payload bytes; and
  4. when fingerprint mode is active, the 64 lowercase hexadecimal certificate fingerprint bytes.

Insert the standard-base64 MAC as the signature:

JSON
{"type":"Ping","token_binding":{"version":2,"scheme":"server_nonce_hkdf_sha256","sequence":1,"signature":"BASE64_MAC","fingerprint":null}}

For binary data, retain the existing inner MessagePack game-data bytes exactly. HMAC the same fields with the domain signalfish.tokenbinding.v2\0binary\0, then send a named-field MessagePack map with this shape (where payload is a MessagePack bin value):

Text Only
{"token_binding": <the same v2 proof map>, "payload": <binary>}

The sequence namespace is shared: for example, JSON sequence 1 followed by binary sequence 2 is valid; another JSON sequence 1 is not.

Normative JSON golden vector (no fingerprint; the same client key bytes produce this vector on HTTP/1.1 and HTTP/2):

  • handshake key: MDEyMzQ1Njc4OWFiY2RlZg==
  • challenge nonce: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
  • derived key (hex): abb5860d3be9f16fad2763e718d0e9a038b7196fd136f24d62cb3ab6fc631da7
  • input: {"zzz":{"y":2,"x":"é"},"type":"Ping","aaa":[3,1]}
  • canonical payload: {"aaa":[3,1],"type":"Ping","zzz":{"x":"é","y":2}}
  • sequence: 1
  • signature: HobFBjbmzHgNF/QoXXFpqNy5s4/InE7+tCYO56+Dqig=

Normative binary golden using the same key/nonce, sequence 2, inner payload hex 81a46461746101:

  • signature: 7/4RSNk/Euc4JnPJqlrMVDqp1l8oLXl+A8jAqDZIt1A=
  • complete named-field MessagePack envelope (hex): 82ad746f6b656e5f62696e64696e6785a776657273696f6e02a6736368656d65b87365727665725f6e6f6e63655f686b64665f736861323536a873657175656e636502a97369676e6174757265d92c372f3452534e6b2f457563344a6e504a716c724d56447170316c386f4c586c2b41386a4171445a497431413dab66696e6765727072696e74c0a77061796c6f6164c40781a46461746101

Client impact: when enabled but not required, none — non-participating clients are unaffected. When required, clients must implement the subprotocol, so roll that out fleet-wide first. Start with enabled=true, required=false, confirm clients negotiate it, then tighten.

Fingerprint binding is a separate tightening step: first deploy trusted client certificates, enable tls.client_auth, and verify mTLS connectivity. Then set require_client_fingerprint=true and update proof generation with the actual leaf-certificate fingerprint. Reconnect tokens issued to certificate A remain claimable only with A. Presenting certificate B returns an invalid-token result without consuming A's token; after rotation to B, use the normal join flow to obtain a new B-bound token. Per-frame integrity, connection replay resistance, and reconnect-credential identity binding are distinct checks and all remain enforced during that transition.

JSON
{
  "security": {
    "transport": {
      "tls": {
        "enabled": true,
        "certificate_path": "/run/secrets/server-chain.pem",
        "private_key_path": "/run/secrets/server-key.pem",
        "client_ca_cert_path": "/run/secrets/client-ca.pem",
        "client_auth": "require"
      },
      "token_binding": {
        "enabled": true,
        "required": true,
        "require_client_fingerprint": true
      }
    }
  }
}

Metrics auth

Protect the metrics endpoints with a shared bearer token.

JSON
{
  "security": {
    "require_metrics_auth": true,
    "metrics_auth_token": "REPLACE_WITH_A_STRONG_TOKEN"
  }
}

Environment equivalent (keep the token out of the file):

Bash
export SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=true
export SIGNAL_FISH__SECURITY__METRICS_AUTH_TOKEN="$(openssl rand -hex 32)"

When to use: any deployment whose /metrics, /v1/metrics, or /metrics/prom endpoints are reachable beyond a trusted network. With require_metrics_auth on, requests must send Authorization: Bearer <token> matching metrics_auth_token; the server fails to start if the token is missing and warns if it is shorter than 16 characters (32 or more recommended — openssl rand -hex 32). Configure Prometheus with the token:

YAML
scrape_configs:
  - job_name: 'signal-fish'
    metrics_path: /metrics/prom
    authorization:
      type: Bearer
      credentials: "REPLACE_WITH_A_STRONG_TOKEN"
    static_configs:
      - targets: ['signal-fish:3536']

If you instead restrict metrics at a reverse proxy (an IP allowlist, see the nginx example), you may leave require_metrics_auth=false.

Batching tuning

Outbound WebSocket batching is opt-in (enable_batching defaults to false) because the flush timer adds up to batch_interval_ms of latency to every relay hop. Enable it for bulk/throughput deployments, then tune the batch size and flush interval.

JSON
{
  "websocket": {
    "enable_batching": true,
    "batch_size": 10,
    "batch_interval_ms": 16,
    "auth_timeout_secs": 10,
    "idle_timeout_secs": 300,
    "socket_send_buffer_bytes": 65536,
    "send_queue_capacity": 1024,
    "control_queue_capacity": 128,
    "slow_consumer_timeout_ms": 5000,
    "max_sojourn_ms": 15000,
    "delivery_stats_interval_secs": 0
  }
}

Environment equivalent:

Bash
export SIGNAL_FISH__WEBSOCKET__ENABLE_BATCHING=true
export SIGNAL_FISH__WEBSOCKET__BATCH_SIZE=10
export SIGNAL_FISH__WEBSOCKET__BATCH_INTERVAL_MS=16
export SIGNAL_FISH__WEBSOCKET__SOCKET_SEND_BUFFER_BYTES=65536
export SIGNAL_FISH__WEBSOCKET__SEND_QUEUE_CAPACITY=1024
export SIGNAL_FISH__WEBSOCKET__CONTROL_QUEUE_CAPACITY=128
export SIGNAL_FISH__WEBSOCKET__SLOW_CONSUMER_TIMEOUT_MS=5000
export SIGNAL_FISH__WEBSOCKET__MAX_SOJOURN_MS=15000

When to use: enable batching only for throughput-oriented deployments (heavy fan-out of bulk data) where fewer, larger writes matter more than per-hop latency. It is off by default so real-time relay traffic (rollback game data is reliable) is never held by the timer. When enabled, only latest traffic waits up to batch_interval_ms to coalesce same-key values; reliable, volatile, and control are still flushed immediately. Raise batch_size and batch_interval_ms to favor throughput. When enable_batching is true, batch_interval_ms must be > 0 and at most 60000 (1 minute), and max_sojourn_ms must exceed it (all enforced at startup). Keep idle_timeout_secs positive in production — it reclaims zombie sockets (0 disables it); auth_timeout_secs must be between 5 and 60.

High-rate game data (rollback netcode): choose the v3 JSON delivery class by meaning. Keep commands and critical events reliable; send frequently replaced state as latest with a stable per-stream key; use volatile only when an omission is acceptable. Raw binary frames are always reliable. Reliable traffic waits when send_queue_capacity fills, while latest and volatile never pace the sender and account for every omission in a prior exact DeliveryReport.

Size send_queue_capacity for burst headroom, but keep it consistent with max_sojourn_ms: reliable traffic closes once its oldest reliable queued or batched item cannot complete within 15 seconds. Control uses its own enqueue age; latest/volatile queue age is handled by the class policy and gets a 15-second write-progress budget only after selection. For a measured drain rate and encoded WebSocket-frame size, check (socket_bytes_ahead + capacity * frame_bytes) / drain_bytes_per_second <= max_sojourn_seconds. Apply the data capacity to reliable data and the separate 128-slot control capacity to reports. A capacity larger than this bound remains valid burst and memory protection, but a completely full queue is expected to hit maximum sojourn before it drains. Close 4002 slow_consumer reports that bounded delivery-contract failure whether the recipient stopped entirely or merely remained below the offered reliable rate; there is no separate oversubscription close code. See the worked queue and freeze budget. slow_consumer_timeout_ms (default 5000) controls how long reliable delivery may wait for capacity; higher values ride out longer stalls but let one recipient pace reliable senders longer. The authoritative failure signal is close code 4002 slow_consumer; the final SLOW_CONSUMER error is best effort.

Keep socket_send_buffer_bytes bounded so the operating system cannot accept a large data tail ahead of later Ping/report frames. The default requests 65536 bytes (0 restores the platform default); the effective value is logged at listener startup because kernels may clamp or account it differently.

Keep control_queue_capacity large enough for bursts of lifecycle and exact accountability traffic. V3 drains this lane before data within the active room generation; the recipient's own room/spectator transitions are generation barriers that drain old-room data first. The connection fails closed if it cannot publish a required gap report before a successor. Setting delivery_stats_interval_secs=0 disables periodic aggregate snapshots only; gap-bearing DeliveryReport frames remain event-driven. See delivery semantics.

Rate limits

Per-player rate limits that bound abusive room creation, join, and signaling traffic.

A room creation consumes one room-creation slot and one join-attempt slot, and is rejected atomically when either budget is exhausted.

JSON
{
  "rate_limit": {
    "max_room_creations": 5,
    "time_window": 60,
    "max_join_attempts": 20,
    "max_signals": 600,
    "max_signal_errors": 60,
    "max_relay_bytes": 268435456,
    "max_room_relay_bytes": 1073741824
  }
}

Environment equivalent:

Bash
export SIGNAL_FISH__RATE_LIMIT__MAX_ROOM_CREATIONS=5
export SIGNAL_FISH__RATE_LIMIT__TIME_WINDOW=60
export SIGNAL_FISH__RATE_LIMIT__MAX_SIGNALS=600

When to adjust:

  • max_room_creations — raise for matchmaking services that legitimately create many rooms per player; lower to throttle room-spam abuse.
  • max_join_attempts — raise if clients retry joins aggressively (flaky networks); lower to curb brute-forcing of room codes.
  • max_signals — raise for v3 WebRTC games with heavy ICE/SDP exchange (mesh topologies signal more than host); lower for relay-only deployments that never signal.
  • max_signal_errors — the ceiling on detailed rejected-signal error responses; lower it to replace validation errors with a generic rate-limit error sooner.
  • max_relay_bytes — the per-sender game-data relay byte budget per window; size it against the games' real submit rates (a full roster of 8 relaying 64 KiB at 30 Hz submits roughly 2 MB/s), and lower it to cap one player's share of the host's egress bill.
  • max_room_relay_bytes — the per-room aggregate game-data relay byte ceiling per window; bounds many individually under-budget senders jointly (a room's admitted submit volume can no longer multiply past the ceiling), and lower it to cap a whole room's share of the host's egress bill. Keep it at or above the per-sender budgets your rooms legitimately use in aggregate.
  • allowed_apps[].max_relay_bytes — the per-tenant override of max_relay_bytes for one application (issue #530); raise it for a paid tier or lower it for a trial tier without moving the server-wide default.
  • time_window — the window (seconds) all the above counts apply over; must be > 0.

These are global defaults; an allowed_apps entry's rate_limit_per_minute overrides per app (see Application ID allowlist).

See also