Skip to content

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):

Bash
cp config.example.json config.json
cargo run

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):

Bash
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):

Bash
SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH=false cargo run

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:

Bash
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:

Bash
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:

Bash
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:

Bash
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:

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

Bash
cargo run -- --print-config

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