The zebra pages (the chat, the 1:1 voice
call, and the multi-party spaces) need exactly two things from a server: a rendezvous relay
so two browsers can find each other, and a TURN server so they
can still connect when both sit behind NAT. Everything else — the crypto,
the modem, the audio — runs in the browser. This page hands you the whole
back end so you can run it for your own community on a single small box.
This is the exact infrastructure behind www.unturf.com/zebra-report,
written out so you can stand up your own. It is a gift — reproduce it,
fork it, harden it. Your members then point the existing pages at your servers
with two URL parameters; nothing about the client needs to change.
One internet-facing box (a $5–$6/mo VPS is plenty for a small community)
running three daemons behind a TLS reverse proxy:
coturn — the STUN/TURN server. STUN tells a browser
its public address; TURN relays the encrypted media when a direct path is
impossible. This is the part that makes calls work across mobile networks and
strict NATs.
a rendezvous relay — a tiny WebSocket service that
pairs two browsers in a "room" and forwards their encrypted connection setup.
It never sees plaintext: the room id is an opaque hash and the setup data is
encrypted with the shared code before it ever reaches the server.
a credential mint — a single HTTP endpoint that hands
each browser a short-lived TURN username/password, so every user draws on
their own quota instead of sharing one static login.
The mint and the relay are the same small program here, but they are independent
— split them if you like. coturn is off-the-shelf.
When the network allows it, the two browsers talk directly and
coturn never touches the media. coturn is the fallback that guarantees a
connection; the rendezvous relay is only used for the few hundred bytes of
setup, then sits idle.
3 · coturn — the TURN relay
Install it (apt install coturn on Debian/Ubuntu) and replace
/etc/turnserver.conf with this. Swap in your box's public IP and a
DNS name you control:
# /etc/turnserver.conf
external-ip=YOUR.PUBLIC.IP
relay-ip=YOUR.PUBLIC.IP
listening-port=3478
realm=turn.example.com# relay allocation range — open these UDP ports in your firewall too
min-port=49152
max-port=50151
# time-limited credentials: the mint computes HMAC-SHA1(secret, expiry).# the secret is appended below at deploy and never committed.
use-auth-secret
# static-auth-secret=<injected at deploy, see step 4>
# abuse quotas — per ephemeral user, so they stay tight as you scale
total-quota=2000
user-quota=6
bps-capacity=400000000
max-bps=2000000
stale-nonce=600
fingerprint
no-cli
no-loopback-peers
no-multicast-peers
log-file=/var/log/coturn/coturn.log
simple-log
Run it under systemd as an unprivileged user (all ports are above 1024, so no
special capabilities are needed):
Firewall: allow inbound UDP 3478 (and TCP 3478 if you
offer TCP relay) plus the whole UDP 49152-50151 range. On a cloud
provider, that means a firewall rule, not just ufw.
4 · the shared secret
coturn and the mint share one secret. The mint signs each ephemeral credential
with it; coturn validates against the same value. Generate your own
— never reuse anyone else's, never print it, never commit it:
# run once, as root, on the box
umask 077
openssl rand -hex 32 > /etc/zebra-turn-secret
chmod 600 /etc/zebra-turn-secret
SECRET=$(cat /etc/zebra-turn-secret)
# wire it into coturn
sed -i '/^static-auth-secret=/d' /etc/turnserver.conf
printf 'static-auth-secret=%s\n' "$SECRET" >> /etc/turnserver.conf
# and into the mint's environment
printf 'ZEBRA_TURN_SECRET=%s\n' "$SECRET" > /etc/zebra-signal.env
chmod 640 /etc/zebra-signal.env
Generate once and persist it: rotating the secret invalidates every credential
already handed out, dropping live calls. Keep the file 600, owned
by root.
5 · minting credentials — /turn-cred
This is the only non-obvious piece, and it is tiny. coturn's
use-auth-secret mode accepts any username whose value is a future
unix timestamp, with the password being
base64(HMAC‑SHA1(secret, username)). So the endpoint just
stamps an expiry and signs it. In Go:
That Access-Control-Allow-Origin: * matters: it is what lets a
browser on any page — including a copy of the zebra page saved to disk and
opened from file:// — fetch a credential. The credential is
short-lived and per-user, so handing it out openly is by design.
6 · the rendezvous relay
The relay is a stateless WebSocket server, a few hundred lines of standard
library, no database. Its whole job:
A browser connects to /zebra-signal?room=<hash>. The room
is a SHA-256 of the shared code, so the server learns nothing about the code.
The first peer in a room is told it is the offerer; the second is the
answerer. (Or "whoever is already present offers when the other joins" —
either rule works, as long as it is deterministic.)
Every message a peer sends is forwarded verbatim to the other peer in the
same room. The payload is the WebRTC offer/answer, already encrypted
in the browser with a key derived from the shared code (PBKDF2 → AES-GCM).
The relay forwards ciphertext it cannot read.
The server sends periodic WebSocket pings so idle calls don't get reaped by
intermediaries, and drops a room when both peers leave.
Run it under systemd as an unprivileged user, reading the secret from the env
file written in step 4:
Browsers require wss:// (TLS) for WebSockets and a secure context
for the crypto, so put a reverse proxy in front that terminates TLS. With
Caddy you get automatic certificates and
the config is four lines:
Caddy fetches a Let's Encrypt certificate on first request. The WebSocket
upgrade is proxied transparently; the * CORS header set by the mint
passes straight through. That is the entire edge.
The 1:1 voice call (zebra-audio) needs only the
relay and coturn. For zebra-spaces — a host
plus co-hosts plus speakers plus an audience of listeners — you also stand
up two small Go services:
zebra-spaces-signal — a multi-member sibling of the
1:1 rendezvous relay. Tracks roles (host > cohost > speaker > listener),
enforces an authority chain (mods can mic-invite/promote/demote/boot,
co-hosts can’t boot the host), and verifies an Ed25519 signature on every
role-change. The relay sees only opaque room ids and ciphertext SDP — a
compromised relay can refuse to forward but can’t forge promotions.
Defaults to port :8091.
zebra-spaces-sfu — a tiny audio-only Selective
Forwarding Unit built on pion/webrtc.
Speakers form a low-latency P2P mesh among themselves; they ALSO publish one
mic track to the SFU. Each listener holds one WebRTC connection to the SFU
and receives every speaker as a separate track — the listener fan-out
is what the mesh alone can’t do. RTP forwards unchanged: no mixing, no
decoding, no transcoding. Listener identity is mandatory (no anonymous
listening), so a moderator’s boot evicts the offender from
every path. HTTP signaling on :8092, ICE on a single UDP mux
port :7882.
Both services live in the same repo as zebra-signal:
git.unturf.com/engineering/unturf/proxy.unturf.com
— under cmd/zebra-spaces-signal/ and cmd/zebra-spaces-sfu/.
Public domain. Each ships a systemd unit and standard build target; the deploy
pattern mirrors zebra-signal exactly. The Caddyfile gets two more
routes:
flush_interval -1 matters: the SFU pushes renegotiation offers
over Server-Sent Events when speakers join or leave, and Caddy will buffer
those events into silence without it.
The SFU advertises a public IP as its host ICE candidate, so set
ZEBRA_SFU_NAT1TO1_IP=<your.public.ip> on the unit and open
UDP 7882 in your firewall — that single port carries all
ICE/RTP via Pion’s UDP mux. No port range like coturn needs.
9 · point your members at it
Now the payoff: nobody needs a modified page. The published
zebra pages read two URL parameters and fall back to the unturf servers only if
they are absent. Send your community a link with your own endpoints:
Or host the page yourself (it is a single self-contained HTML file) and serve it
from the same box. Either way, the call is established through your
relay and, when needed, relayed through your coturn. The same two
parameters work on the text chat (index.html) and the voice call
(zebra-audio.html).
Saved a copy to disk? It still works from file:// — the crypto
runs in a secure context and the * CORS header lets the saved file
fetch credentials — as long as your relay and TURN server are reachable.
10 · how many users can it carry
The rendezvous relay is nearly free: it moves a few hundred bytes per call setup
and then idles, so a tiny box pairs thousands of rooms. coturn is the
ceiling, and only for relayed calls (direct peer-to-peer calls cost it
nothing). Each relayed voice call is bidirectional audio — tens of kbit/s
per leg. With bps-capacity=400000000 (400 Mbit/s) the limit is
whatever your VPS's actual uplink and monthly transfer allow, long before coturn
itself strains.
The user-quota and total-quota lines cap concurrent
allocations to blunt abuse. Raise total-quota as you grow; keep
user-quota small (a handful of allocations per credential is plenty
for one call). Because credentials are per-user and expire, a leaked one is
worthless within hours.
11 · it's a gift
This stack is open intellectual capital — take it and run a community the
unturf servers will never see or meter. Patch it, harden it, pass it on. Every
box that runs its own relay makes the whole mesh more resilient and less
centralised, which is the entire point.