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.
Authentication (authorized_apps)¶
Require every WebSocket client to authenticate as a registered app, and set per-app limits.
{
"security": {
"require_websocket_auth": true,
"cors_origins": "https://yourgame.com",
"authorized_apps": [
{
"app_id": "my-game",
"app_secret": "REPLACE_WITH_A_STRONG_SECRET",
"app_name": "My Awesome Game",
"max_rooms": 100,
"max_players_per_room": 16,
"rate_limit_per_minute": 60
}
]
}
}
Environment equivalent (the app list is a JSON array; app_secret is best kept
out of the config file):
export SIGNAL_FISH__SECURITY__REQUIRE_WEBSOCKET_AUTH=true
export SIGNAL_FISH__SECURITY__CORS_ORIGINS="https://yourgame.com"
export SIGNAL_FISH__SECURITY__AUTHORIZED_APPS='[
{"app_id":"my-game","app_secret":"REPLACE_WITH_A_STRONG_SECRET",
"app_name":"My Awesome Game","max_rooms":100,
"max_players_per_room":16,"rate_limit_per_minute":60}
]'
When to use: any non-local deployment. max_rooms, max_players_per_room, and
rate_limit_per_minute are optional per-app overrides — omit them to fall back
to the global limits. Change the example app_secret
(CHANGE_ME_BEFORE_PRODUCTION) before deploying. See
Authentication for the client handshake.
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.
{
"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):
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_secretis 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 leastcredential_ttl_secsfor 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.
{
"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:
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¶
Route specific games to a WebRTC or host topology while everything else stays on the relay floor.
{
"session": {
"default_topology": "relay",
"game_topology_mappings": {
"chess": "mesh",
"BoardGame": "host"
},
"enable_webrtc": true,
"enable_direct": true
}
}
Environment equivalent (the mapping is a JSON object of game name to topology):
export SIGNAL_FISH__SESSION__DEFAULT_TOPOLOGY=relay
export SIGNAL_FISH__SESSION__GAME_TOPOLOGY_MAPPINGS='{"chess":"mesh","BoardGame":"host"}'
When to use: when different games want different transports — e.g. a low-latency
title on mesh (full WebRTC peer mesh) and a turn-based title on host while
unmapped games default to relay. Valid topologies are relay, host, and
mesh. Every upgrade gracefully degrades to the relay floor, so this is always
safe: if both enable_webrtc and enable_direct are false, mapped games
simply fall back to relay (the server warns but still starts).
Token binding¶
Token binding cryptographically ties each WebSocket frame to the connection that sent it, so a stolen session token cannot be replayed on a different connection. It is an optional zero-trust hardening layer, off by default, and is currently undocumented elsewhere.
{
"security": {
"transport": {
"token_binding": {
"enabled": true,
"required": false,
"require_client_fingerprint": false,
"subprotocol": "signalfish.tokenbinding.v1",
"scheme": "sec_websocket_key_sha256"
}
}
}
}
Environment equivalent:
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:
enabledturns on negotiation of the token-binding WebSocket subprotocol (subprotocol, defaultsignalfish.tokenbinding.v1). Withenabled=trueandrequired=false, clients that advertise the subprotocol get per-frame proofs; clients that do not still connect normally — a safe, opt-in rollout.required=truerejects clients that do not advertise the subprotocol. Set this only after every client in your fleet supports token binding, or you will lock out existing clients.require_client_fingerprint=trueadditionally requires a verified mTLS client certificate fingerprint on every signed frame; pair it with TLSclient_authset torequireand aclient_ca_cert_path(see TLS).schemeselects the per-frame signing scheme (defaultsec_websocket_key_sha256).
Client impact: when enabled but not required, none — non-participating
clients are unaffected. When required (or require_client_fingerprint),
clients must implement the subprotocol (and present a client certificate),
so roll those out fleet-wide first. Start with enabled=true, required=false,
confirm clients negotiate it, then tighten.
Metrics auth¶
Protect the metrics endpoints with a shared bearer token.
{
"security": {
"require_metrics_auth": true,
"metrics_auth_token": "REPLACE_WITH_A_STRONG_TOKEN"
}
}
Environment equivalent (keep the token out of the file):
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:
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 messages are batched to improve throughput. Tune the batch size and flush interval.
{
"websocket": {
"enable_batching": true,
"batch_size": 10,
"batch_interval_ms": 16,
"auth_timeout_secs": 10,
"idle_timeout_secs": 300,
"send_queue_capacity": 1024,
"control_queue_capacity": 128,
"slow_consumer_timeout_ms": 5000,
"max_sojourn_ms": 15000,
"delivery_stats_interval_secs": 0
}
}
Environment equivalent:
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__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: the defaults (batch_size=10, batch_interval_ms=16, about one
frame interval at 60 fps) suit most games. Raise batch_size and
batch_interval_ms to favor throughput under heavy fan-out; lower
batch_interval_ms to favor latency. batch_interval_ms must be > 0 when
enable_batching is true (a zero flush interval is rejected at startup). Set
enable_batching=false only for latency-critical, low-volume traffic where the
per-flush delay matters more than syscall amortization. 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: the default closes a connection once its oldest outbound item
cannot complete its socket write within 15 seconds, even if the client keeps
pinging. The age check spans every queue lane plus batched/in-flight work.
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 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.
{
"rate_limit": {
"max_room_creations": 5,
"time_window": 60,
"max_join_attempts": 20,
"max_signals": 600,
"max_signal_errors": 60
}
}
Environment equivalent:
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 rejected signal attempts; lower it to disconnect misbehaving or buggy clients sooner.time_window— the window (seconds) all the above counts apply over; must be> 0.
These are global defaults; an authorized_apps entry's rate_limit_per_minute
overrides per app (see Authentication).
See also¶
- Configuration reference — every key, default, and env override
- Run Modes — how to run each mode, with commands
- Pre-deployment checklist — verify before going live
- Authentication — client handshake and error codes
- TURN Deployment — self-hosted coturn and rotation