Docker Compose
The repository’s deploy/compose runs the whole sharing service on one Linux server with Docker:
| Service | What it does |
|---|---|
server |
The SpecDeck server, from the signed release image (or built from the repository) |
caddy |
Gets a TLS certificate for the server’s domain and passes connections through |
coturn |
The TURN relay, on the host’s network |
Check what you need first: a server with a public IPv4 address, two DNS names and open ports.
1. DNS
Section titled “1. DNS”Create two records pointing at the server’s public address, for example:
| Name | Type | Value |
|---|---|---|
specdeck.example.com |
A | 203.0.113.10 |
turn.example.com |
A | 203.0.113.10 |
2. Firewall
Section titled “2. Firewall”Open these ports to the internet:
80/tcpand443/tcpfor Caddy;3478/udpfor TURN;- the relay range,
49160-49259/udpby default (TURN_MIN_PORTtoTURN_MAX_PORT).
3. Configure
Section titled “3. Configure”Install Docker with the Compose plugin, then get the repository at the release you want to run:
git clone --branch v1.0.0 https://github.com/nickhartjes/specdeck.gitcd specdeck/deploy/composecp .env.example .envFill in .env:
| Setting | Value |
|---|---|
SPECDECK_VERSION |
The release to run, such as 1.0.0: the same version you checked out |
SPECDECK_DOMAIN |
The server’s domain, such as specdeck.example.com |
TURN_DOMAIN |
The TURN domain, such as turn.example.com |
TURN_EXTERNAL_IP |
The server’s public IPv4 address |
TURN_SECRET |
A random secret shared by the server and coturn: openssl rand -hex 32 |
TURN_MIN_PORT, TURN_MAX_PORT |
The relay range you opened in the firewall |
TURN_TOTAL_QUOTA |
Optional: relay allocations in total, as many as the relay range has ports (default 100) |
Keep .env private: anyone with TURN_SECRET can use your relay.
4. Start
Section titled “4. Start”docker compose up -dThis pulls the server image for SPECDECK_VERSION from ghcr.io/nickhartjes/specdeck-server, with the web viewer built in. Every release publishes it for amd64 and arm64 servers, signed by the release workflow; to check the signature before you start, see the web viewer’s code. Caddy gets its certificate as soon as the DNS records resolve to the server.
Or build it from source
Section titled “Or build it from source”docker compose up -d --buildThis builds the image from the checked-out release instead, under the same name, which takes a few minutes. The build downloads from crates.io, the npm registry (registry.npmjs.org) and Docker Hub, so the server needs to reach them. If your network only allows a company npm registry, build the image once with your npm config as a build secret, which is not stored in the image: docker build --secret id=npmrc,src=$HOME/.npmrc -f docker/server.Dockerfile -t ghcr.io/nickhartjes/specdeck-server:<SPECDECK_VERSION> ../.., then start without --build.
Once it runs, https://<SPECDECK_DOMAIN>/ is the web viewer, and hosts on this service copy join links that open there.
Check that the service answers:
curl https://specdeck.example.com/health # prints: okdocker compose logs -f server # follow the server's logThe server logs one line per event, such as a viewer joining or a wrong code:
2026-11-09T10:00:00.123456Z INFO room.opened room=1 rooms_open=12026-11-09T10:00:41.004812Z INFO peer.joined room=1 peer=1 viewers_on_host=12026-11-09T10:02:13.551230Z INFO peer.refused reason=unknown_roomSPECDECK_LOG_LEVEL in compose.yaml sets how much it logs. See Logs for every event and what is never logged.
Then connect the apps.
Update
Section titled “Update”git fetch --tags && git checkout v1.1.0 # the new release# set SPECDECK_VERSION=1.1.0 in .envdocker compose pulldocker compose up -d --remove-orphansIf you build from source, run docker compose up -d --build --remove-orphans after the checkout instead of pulling.
Then check that the new release runs (from 1.7 on, the server states its version):
curl https://specdeck.example.com/web-viewer.json # prints, for example: {"protocol":6,"version":"1.7.0"}--remove-orphans matters once, when you update from 1.6 or earlier: the service was called signalling then, and is server now, so it stops the old container.
Restarting the server does not end sessions that are already connected; new viewers can join again once it is back.
- coturn uses the host’s network (
network_mode: host), so it advertises the server’s real address and needs no published port per relay port. It refuses to relay to private, loopback and other special-purpose ranges, over IPv4 and IPv6, so the relay cannot be used to reach the server’s own services or its local network. - The server needs its public address on its own interface. Behind 1:1 NAT, coturn needs extra settings (
--external-ip=<public>/<private>and allowing its own private address as a peer) that this setup does not include. - Each relayed viewer uses about two relay ports; 100 ports cover about 50 relayed viewers at once. Most connections are direct and use none. If you widen the relay range, raise
TURN_TOTAL_QUOTAwith it. - coturn limits each relayed connection to 20 Mbit/s, so one session cannot take the whole uplink.