Run Modes¶
How to run Signal Fish Server in each of its operating modes, with a copy-pasteable command for every row. Every key referenced here is a real configuration key — see the full configuration reference for defaults and environment-variable spellings, and Deployment for container, reverse-proxy, and cloud-provider details.
The server is a single self-contained binary with zero external runtime
dependencies. It starts with safe defaults and reads config.json from the
working directory when present; every key can also be overridden with a
SIGNAL_FISH__... environment variable (double underscores separate nested
keys — see environment variable format).
How modes compose¶
The modes below are not mutually exclusive — they are feature layers you combine. A production deployment is typically "Prod relay v2 + app allowlist" plus "TLS (or reverse proxy)" plus "Metrics + Prometheus", and optionally the v3 WebRTC / TURN layers on top. v3 rooms are mesh-first by default (peer-to-peer WebRTC is attempted first; the relay floor is the fallback), so the v3 layer needs no opt-in — TURN remains the optional layer for peers that cannot hole-punch.
Mode reference¶
| Mode | What it does | Key config (file keys + env overrides) | Exact command |
|---|---|---|---|
| Dev (relay v2, open app IDs) | v2 clients relay over plain WebSocket with open CORS and no app-ID restriction; v3 clients get the mesh-first default — for local development | security.enforce_app_id_allowlist=false, security.cors_origins="*" (env: SIGNAL_FISH__SECURITY__ENFORCE_APP_ID_ALLOWLIST=false) |
cp config.example.json config.json && cargo run |
| Prod relay v2 + app allowlist | v2 relay with public app-ID allowlisting and locked-down CORS | security.enforce_app_id_allowlist=true, security.allowed_apps=[…], security.cors_origins="https://yourgame.com" (env: SIGNAL_FISH__SECURITY__ENFORCE_APP_ID_ALLOWLIST=true) |
export SIGNAL_FISH__SECURITY__METRICS_AUTH_TOKEN="$(openssl rand -hex 32)"; export SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=true; cargo run -- --validate-config && cargo run |
| Relay-only (mesh off) | Pins every room to the server relay floor: no peer-to-peer attempts, pre-v3-compatible behavior | session.default_topology="relay" (env: SIGNAL_FISH__SESSION__DEFAULT_TOPOLOGY=relay) |
SIGNAL_FISH__SESSION__DEFAULT_TOPOLOGY=relay SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=false cargo run |
| v3 WebRTC mesh/host + STUN | Mesh-first default: v3 rooms settle on the richest WebRTC rung their members negotiated, using STUN to hole-punch | session.default_topology="mesh" (default; "host" for host-star-only), session.enable_webrtc=true (default), turn.stun_urls (public STUN by default) |
SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=false cargo run |
| v3 + TURN | Adds a self-hosted coturn relay for the ~15–20% of peers that cannot hole-punch; the server mints ephemeral coturn credentials | turn.enabled=true, turn.urls=["turn:turn.yourgame.com:3478"], turn.static_auth_secret=<shared>, turn.credential_ttl_secs=3600 (env: SIGNAL_FISH__TURN__STATIC_AUTH_SECRET) |
export TURN_STATIC_AUTH_SECRET="$(openssl rand -hex 32)"export SIGNAL_FISH__TURN__STATIC_AUTH_SECRET="$TURN_STATIC_AUTH_SECRET"docker compose --profile turn up -d |
| TLS (built-in) | Terminates HTTPS/wss:// in the server itself |
security.transport.tls.enabled=true, security.transport.tls.certificate_path, security.transport.tls.private_key_path (env: SIGNAL_FISH__SECURITY__TRANSPORT__TLS__ENABLED=true) |
SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=false cargo run (with the three TLS keys set) |
| TLS (reverse proxy) | Terminates TLS at nginx/Caddy; server stays plain ws:// on loopback |
none on the server; configure the proxy (see reverse proxy setup) | SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=false cargo run (server) + proxy in front |
| Metrics + Prometheus | Exposes JSON + Prometheus metrics, optionally behind a bearer token | security.require_metrics_auth=true, security.metrics_auth_token=<token> (env: SIGNAL_FISH__SECURITY__METRICS_AUTH_TOKEN) |
SIGNAL_FISH__SECURITY__METRICS_AUTH_TOKEN="$(openssl rand -hex 32)" SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=true cargo run |
| Externally routed isolated deployments | An application-owned directory chooses one independent, single-process room home before any peer opens its WebSocket; Signal Fish does not share or hand off room state | Optional observability metadata: server.room_code_prefix="USE", server.region_id="us-east" (env: SIGNAL_FISH__SERVER__ROOM_CODE_PREFIX=USE) |
After deploying the external directory: SIGNAL_FISH__SERVER__ROOM_CODE_PREFIX=USE SIGNAL_FISH__SERVER__REGION_ID=us-east SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=false cargo run |
Per-feature JSON snippets (with env equivalents and "when to use") live in the configuration recipes.
Mode details¶
Dev (relay v2, open app IDs)¶
The fastest way to run the server. The compiled defaults fail closed:
metrics authentication is enabled with no token, so a bare cargo run
refuses to start. The example config disables metrics auth and app-ID
allowlisting for local use (file logging stays off, matching the compiled
default; enable it with logging.enable_file_logging=true if you want
rolling files):
The example file config.example.json
ships with security.enforce_app_id_allowlist=false and security.cors_origins="*",
which is convenient for development but must never reach production. Health
check: curl http://localhost:3536/v2/health.
Prod relay v2 + app allowlist¶
Turn on public app-ID allowlisting, register your app labels, and lock down CORS. Validate first, then run (the compiled defaults enable metrics authentication, so a real deployment sets a token):
export SIGNAL_FISH__SECURITY__METRICS_AUTH_TOKEN="$(openssl rand -hex 32)"
export SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=true
cargo run -- --validate-config && cargo run
See the application-ID recipe
for the allowed_apps shape, and Application identification for the
client handshake. --validate-config exits non-zero on a bad config, so it is
safe to chain with &&.
v3 WebRTC mesh/host + STUN¶
v3 rooms are mesh-first by default: the server emits a v3 SessionPlan and
peers connect over WebRTC whenever every room member negotiated a
peer-to-peer-capable rung (any member that cannot — v2 or relay-only — floors
the room to relay). turn.stun_urls already defaults to a public STUN server,
so no extra configuration is needed (the env override relaxes the
fail-closed metrics gate for a local trial — a deployment instead sets a
metrics token, as the Metrics row shows):
Pin session.default_topology="host" for host-star-only sessions, or
session.default_topology="relay" to turn peer-to-peer attempts off entirely.
For a per-game mapping such as {"chess":"host"}, see the
topology recipe. STUN is
effectively free and handles roughly 80–85% of connections; the rest need TURN.
v3 + TURN¶
TURN is fully self-hosted: the server mints short-lived coturn REST
credentials from a shared turn.static_auth_secret and never contacts a
third-party cloud. The repository's
docker-compose.yml
ships a coturn service behind the turn compose profile. The same secret
must be set on both coturn and the signaling server:
export TURN_STATIC_AUTH_SECRET="$(openssl rand -hex 32)"
export TURN_REALM="turn.yourgame.com"
export SIGNAL_FISH__TURN__STATIC_AUTH_SECRET="$TURN_STATIC_AUTH_SECRET"
export SIGNAL_FISH__TURN__ENABLED=true
docker compose --profile turn up -d
A plain docker compose up (no profile) is unchanged and starts only the
signaling server. The coturn service refuses to start with an empty secret. See
the TURN deployment guide for the ephemeral-credential
scheme, the relay port range, secret rotation, and capacity planning, and the
TURN/STUN recipe for the [turn] block.
TLS: built-in vs reverse proxy¶
Signaling must run over wss:// in production: WebRTC DTLS fingerprints
travel through the signaling channel inside the SDP, so plaintext ws:// lets an
on-path attacker defeat WebRTC encryption (see
signaling must run over wss://).
You have two ways to satisfy this.
Built-in TLS terminates HTTPS in the server:
SIGNAL_FISH__SECURITY__TRANSPORT__TLS__ENABLED=true \
SIGNAL_FISH__SECURITY__TRANSPORT__TLS__CERTIFICATE_PATH=/etc/ssl/signal-fish/fullchain.pem \
SIGNAL_FISH__SECURITY__TRANSPORT__TLS__PRIVATE_KEY_PATH=/etc/ssl/signal-fish/privkey.pem \
SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=false \
cargo run
Reverse-proxy TLS terminates at nginx or Caddy and forwards plain ws:// to the
server on loopback; the server needs no TLS keys. This is the common deployment —
see the reverse proxy setup. The
TLS recipe shows both as JSON.
Metrics + Prometheus¶
Metrics are served at /metrics (and /v1/metrics) as JSON and at
/metrics/prom for Prometheus. Per-room session records (one record per
room, active and completed: identity, roster churn, transport outcome,
traffic, close reason) are served
at /metrics/sessions (and /v1/metrics/sessions) as bounded JSON. Active
records list oldest first; completed records list newest first; both lists
carry explicit truncation flags when capped. Completed records carry a
monotonic seq; ?since=<seq> cursor-filters the completed list so a
scraper misses no completion that stayed inside the retention ring, and
?applicationId=<uuid> restricts both lists to one application's rooms.
The applicationId parameter scopes the response only — it is caller-side
view filtering, not access control: the bearer token still grants every
application's records. Session records include room
codes (join capabilities) and per-application attribution, so the metrics
token is operator-level: share it only with principals that may see every
app's rooms. Protect
them with a bearer token: when
security.require_metrics_auth=true, requests must send
Authorization: Bearer <token> matching security.metrics_auth_token:
SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=true \
SIGNAL_FISH__SECURITY__METRICS_AUTH_TOKEN="$(openssl rand -hex 32)" \
cargo run
Validation fails fast if require_metrics_auth is on but no token is set. Point
Prometheus at /metrics/prom (see
Prometheus configuration). The
metrics-auth recipe covers the scrape
config with a bearer token.
Externally routed isolated deployments¶
Signal Fish does not support a fleet of interchangeable active processes behind
a generic load balancer. Room state, reconnect tokens, routes, and sequence
counters are process-local, and the room code arrives only after the WebSocket
upgrade. Neither cookie stickiness nor server.room_code_prefix can establish
the required room home by itself.
You may operate several isolated deployments only when an application-owned
directory chooses the home before clients connect and sends every initial
connection and reconnect for that room to the same process. A prefix can be part
of that external routing scheme, and server.region_id can label metrics, but
both are metadata rather than coordination:
SIGNAL_FISH__SERVER__ROOM_CODE_PREFIX=USE \
SIGNAL_FISH__SERVER__REGION_ID=us-east \
SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=false \
cargo run
The prefix is normalized to uppercase and must contain only ASCII letters or
digits. It must be shorter than protocol.room_code_length; hyphenated region
names such as us-east- are invalid room-code prefixes.
See the single-instance deployment contract for the exact boundary and failure catalog, and the scaling architecture notes for capacity drivers and future extension seams.
CLI flags¶
The binary accepts a small set of flags (run --help for the full list):
| Flag | Effect |
|---|---|
--validate-config (-c) |
Load and validate the config, print a summary, and exit. Non-zero exit on failure. |
--print-config |
Print the resolved config (file + environment overrides) as JSON, then exit. Secrets are redacted. |
--print-config-evidence |
Print the redacted resolved config and SHA-256 fingerprints, then exit. |
--version |
Print the version and exit. |
--validate-config, --print-config, and --print-config-evidence are mutually exclusive.
--validate-config in CI¶
Run it as a pre-deployment gate — it exits non-zero on an invalid or insecure config, so it fails the pipeline before a bad config ships:
cargo run -- --validate-config
# or against a built image:
docker run --rm -v ./config.json:/app/config.json:ro \
ghcr.io/ambiguous-interactive/signal-fish-server:latest --validate-config
It prints a short summary (port, TLS enabled, metrics auth required, reconnection enabled, max players, region) so CI logs capture the effective posture.
--print-config redaction¶
--print-config serializes the resolved config (after environment
overrides) so you can confirm what the server actually loaded. Every secret is
replaced with <redacted> before printing — this covers
security.metrics_auth_token, session.ice_servers[*].credential, and
turn.static_auth_secret. A set
secret shows as <redacted>; an unset one stays empty/null, so you can
still tell "configured" from "missing". TLS certificate and key paths are file
locations, not secrets, and stay visible.
Config fingerprints¶
--print-config-evidence prints effective, the complete redacted config,
effective_sha256, its digest, and loaded_sha256, the digest of the full
loaded config before redaction. Each digest hashes compact JSON with sorted
object keys and UTF-8 strings. A secret change alters loaded_sha256 without
printing the secret. Keep these fingerprints private with the run artifacts.
This command loads settings and exits before binding a listener. It does not
inspect a running process or replace --validate-config. Use the same binary,
working directory, config sources, and environment as the deployment. The
external host evidence guide
shows how to capture and attach the result.
See also¶
- Configuration reference — every key, default, and env override
- Configuration recipes — per-feature JSON + env snippets
- Pre-deployment checklist — what to verify before going live
- Deployment — Docker, reverse proxies, cloud providers
- TURN Deployment — self-hosted coturn and credential rotation