Skip to content

Configuration reference

specdeck-server reads its settings from the environment:

Variable Default Meaning
SPECDECK_LISTEN 0.0.0.0:8080 Address and port to listen on
SPECDECK_TURN_SECRET none coturn’s static-auth-secret. Without it, the service hands out no TURN credentials and only direct connections work
SPECDECK_TURN_URLS none Comma-separated TURN URLs as the apps reach them, for example turn:turn.example.com:3478?transport=udp
SPECDECK_TURN_TTL_SECONDS 43200 (12 hours) How long TURN credentials stay valid. A relayed session keeps using them, so cover a long working day
SPECDECK_TRUSTED_PROXIES 0 How many reverse proxies sit in front of the service. The client address for rate limiting is then read from X-Forwarded-For, counting this many entries from the right
SPECDECK_MAX_CONNECTIONS 2000 Open connections in total. See Limits
SPECDECK_MAX_CONNECTIONS_PER_IP 100 Open connections from one client network. See Limits
SPECDECK_MAX_ROOMS 4000 Open rooms in total. See Limits
SPECDECK_LOG_LEVEL info error, warn, info or debug. When it is unset, a RUST_LOG that is a plain level, such as debug, is used instead. See Logs
SPECDECK_LOG_FORMAT plain plain for text lines, json for one JSON object per line. See Logs

It serves two paths:

Path What
/signal The WebSocket the apps connect to
/health Answers ok, for health checks

The service keeps rooms in memory, so run exactly one instance.

The service bounds everything it holds, so one client cannot use it up for everyone else. When a limit refuses a connection, a room or a join, the apps say the sharing service is busy and keep retrying where they already would. Each limit that is reached is logged as a limit.reached warning with its name and count, at most once a minute, without client addresses; see Logs.

Limit Default Protects against
Connections in total (SPECDECK_MAX_CONNECTIONS) 2,000 Running the service out of memory by opening sockets. 2,000 connections take about 35 MiB
Connections per client network (SPECDECK_MAX_CONNECTIONS_PER_IP) 100 One client taking every connection. A client network is an IPv4 address, or the /64 of an IPv6 address, because one IPv6 client usually holds a whole /64
Rooms in total (SPECDECK_MAX_ROOMS) 4,000 Running the service out of memory through rooms
Rooms per host 4 One connection opening unlimited rooms. The app holds at most 3
Room id length 64 bytes Huge keys in the service’s tables
Viewers per host 40 Flooding one host with viewers. The app admits at most 20
Time to host or join 30 seconds Connections that hold a place and do nothing
Connection set-up messages 100 at once, then 20 per second, per viewer connection Using the service to flood a host or viewer
Backlog per connection 256 messages, 256 KiB A client that stops reading and makes the service buffer for it; the service closes that connection
Failed joins 10 per client network per minute Guessing invite codes
Join attempts per room 60 per minute Guessing one session’s code from many addresses

Every viewer and every host keeps a connection open for the whole session. If your team shares one office address and more than 100 people host or watch at once, raise SPECDECK_MAX_CONNECTIONS_PER_IP.

The service gives TURN credentials only to a connection that hosts a room or has joined one, and the same credentials each time it asks. Apps from earlier releases ask before they join; the service then waits 2 seconds for the room, so their hosts keep the relay, but their viewers join without it and need a direct connection until they update.

The service writes its log to standard output, one line per event: docker compose logs server or kubectl logs shows it. Each line has the time (UTC), the level, the event’s name and its fields:

2026-11-09T10:00:00.123456Z INFO peer.joined room=3 peer=7 viewers_on_host=2

With SPECDECK_LOG_FORMAT=json, the same event is one JSON object, for a log collector:

{"event":"peer.joined","level":"INFO","peer":7,"room":3,"time":"2026-11-09T10:00:00.123456Z","viewers_on_host":2}

The provided Compose file logs plain text and the Kubernetes manifest JSON, both at info.

Event Level Fields When
service.started info version, turn, web_viewer The service starts; whether it hands out TURN credentials and serves the web viewer
service.no_turn warn No TURN relay is set, so only direct connections work
config.invalid warn setting SPECDECK_LOG_LEVEL, SPECDECK_LOG_FORMAT or RUST_LOG has a value the service does not understand; it uses the default
room.opened info room, rooms_open A host opens a room for an invite code
room.closed info room, viewers, lasted_s, why A room closes: why is closed when the host replaced its code or ended the session, host_left when its connection ended. viewers joined through it; lasted_s is how long it was open
room.refused info reason A host cannot open a room: room_taken, busy or bad_message
peer.joined info room, peer, viewers_on_host A viewer joins
peer.left info peer, lasted_s A viewer’s connection to the service ends
peer.refused info reason A join or a connection is turned away: unknown_room (wrong or old code), rate_limited (too many wrong codes), busy (a limit), bad_message
socket.error info kind A connection fails: reset (it broke off), too_large, protocol, bad_message (not a SpecDeck message), backlog_full (it stopped reading)
socket.idle_closed debug A connection that neither hosted nor joined within 30 seconds is closed
limit.reached warn limit, count A limit refused something: connections, connections_per_network, rooms, rooms_per_host, viewers_per_host, signals_per_viewer or backlog
service.stats info connections, rooms, viewers, turn_issued Every 5 minutes, when something changed: what is open now, and how many connections got TURN credentials since the last one

room and peer are numbers the service gives out in order from 1 each time it starts, so you can follow one session through the log. They are not the room id or the connection id, and say nothing about them.

Bursts. peer.refused, room.refused and socket.error are logged the first time for each reason in a minute. The rest of that minute is counted and logged as one more line with count, so a flood of wrong codes or connections cannot flood the log. At debug, each one is logged.

What is never logged, at any level and in either format: room ids, invite codes, connection ids, the messages that set up a connection (session descriptions and ICE candidates), TURN credentials, error text, and client IP addresses. Only the service’s own events are written, not those of the libraries it uses. There is no setting to add addresses; if you need them, your reverse proxy’s or ingress’s access log has them.

Levels. info logs everything above. warn logs only limits, a missing relay and setting problems, so it no longer says when sessions happen. debug adds idle connections and logs every refusal and error on its own line.

The service keeps no log files: how long the log is kept is up to Docker, your cluster or your log collector.

The server signs TURN credentials with the shared secret (the TURN REST API scheme), so coturn needs:

Option Why
--use-auth-secret, --static-auth-secret=<secret> Accept the credentials the server hands out
--external-ip=<public address> The address coturn advertises in relayed addresses
--min-port, --max-port The relay ports; open the same range in the firewall
--no-tcp, --no-tls, --no-tcp-relay The apps use TURN over UDP only. coturn 4.18 and later leave DTLS off by default and refuse --no-dtls; add it only on older versions
--denied-peer-ip=<range> for each private, loopback, link-local and other special-purpose range, IPv4 and IPv6 Keeps the relay from reaching into your own network
--user-quota=40 Allocations per credential. One credential cannot take the whole relay; a host needs one per viewer
--total-quota=<relay ports> Allocations in total, as many as there are relay ports, so a full relay says so instead of failing to find a port
--max-bps=2500000 Bandwidth per allocation: 20 Mbit/s, enough for a late joiner’s catch-up

coturn counts bandwidth in bytes per second, despite the option’s name: multiply megabits by 125,000. The relay’s total bandwidth is at most the allocation quota times 20 Mbit/s; to lower it, shrink the relay range and --total-quota. Do not add --bps-capacity: coturn reserves the per-allocation rate twice, for each allocation, against it, so 200 Mbit/s would admit only 5 allocations, about two viewers.

The denied ranges cover the IPv6 forms of IPv4 addresses too (IPv4-mapped, NAT64, 6to4 and Teredo). Without them, a relay on a server with IPv6 would reach 10.0.0.1 as ::ffff:10.0.0.1. No range may start at ::: coturn then refuses public IPv4 peers as well.

The Compose file and Kubernetes manifest have the complete list. To check a relay, run node scripts/turn-denied-peers.mjs wss://<domain>/signal from a checkout: it asks the relay to reach each kind of address and reports which it refuses.

TURN URLs must use turn: with ?transport=udp. The app’s WebRTC library has no TURN over TCP or TLS, so turns: URLs and TCP transports do not work, and viewers on networks that block UDP entirely cannot connect.