host your own

run your own zebra community  ·  self-hosted TURN + rendezvous on one edge box  ·  how it works  ·  open the chat  ·  unturf

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.

▶ jump to "point your members at it"

1 · what you are building

One internet-facing box (a $5–$6/mo VPS is plenty for a small community) running three daemons behind a TLS reverse proxy:

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.

2 · the shape of it

browser A rendezvous (wss, encrypted SDP) browser B ┌──────────┐ ◄──────────────────────────────────────────────────────► ┌──────────┐ │ zebra │ │ zebra │ │ page │ ──┐ ┌── │ page │ └──────────┘ │ GET /turn-cred (https) → short-lived HMAC cred │ └──────────┘ ▲ │ │ ▲ │ └────────────────────────┐ ┌────────────────┘ │ │ media (DTLS-SRTP, encrypted) ▼ ▼ media (DTLS-SRTP) │ │ ┌─────────────────────────────┐ │ └─────────────────────────►│ coturn TURN/STUN :3478 │◄──────────────┘ │ relay UDP 49152-50151 │ ┌──────────────────────────────┴─────────────────────────────┴───────────────┐ │ your edge box Caddy (auto-TLS) │ │ wss://you/zebra-signal ─► relay :8090 │ │ https://you/turn-cred ─► mint :8090 │ └──────────────────────────────────────────────────────────────────────────────┘

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

# /etc/systemd/system/coturn.service [Unit] Description=coturn TURN/STUN relay for WebRTC NAT traversal After=network-online.target Wants=network-online.target [Service] Type=simple User=turnserver Group=turnserver ExecStart=/usr/bin/turnserver -c /etc/turnserver.conf Restart=on-failure RestartSec=5 LogsDirectory=coturn LogsDirectoryMode=0750 PrivateTmp=true ProtectSystem=full ProtectHome=true NoNewPrivileges=true [Install] WantedBy=multi-user.target

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:

const turnTTL = 12 * 3600 // seconds a credential stays valid func turnCred(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") // browsers (and file:// copies) can fetch w.Header().Set("Content-Type", "application/json") secret := os.Getenv("ZEBRA_TURN_SECRET") if secret == "" { http.Error(w, `{"error":"turn unavailable"}`, 503); return } username := strconv.FormatInt(time.Now().Unix()+turnTTL, 10) mac := hmac.New(sha1.New, []byte(secret)) mac.Write([]byte(username)) json.NewEncoder(w).Encode(map[string]any{ "username": username, "credential": base64.StdEncoding.EncodeToString(mac.Sum(nil)), "ttl": turnTTL, "stun": []string{"stun:turn.example.com:3478"}, "uris": []string{ "turn:turn.example.com:3478?transport=udp", "turn:turn.example.com:3478?transport=tcp", }, }) }

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:

Run it under systemd as an unprivileged user, reading the secret from the env file written in step 4:

# /etc/systemd/system/zebra-signal.service [Unit] Description=zebra-signal — WebRTC rendezvous relay + TURN credential mint After=network.target [Service] Type=simple User=www-data Group=www-data Environment=ZEBRA_SIGNAL_ADDR=:8090 EnvironmentFile=-/etc/zebra-signal.env ExecStart=/usr/local/bin/zebra-signal Restart=on-failure RestartSec=5 NoNewPrivileges=true PrivateTmp=true ProtectSystem=full ProtectHome=true [Install] WantedBy=multi-user.target

7 · TLS + reverse proxy

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:

# Caddyfile turn.example.com { handle /zebra-signal* { reverse_proxy localhost:8090 } handle /turn-cred { reverse_proxy localhost:8090 } }

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.

8 · zebra-spaces — multi-party voice rooms (optional)

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:

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:

# Caddyfile (added to your existing block) handle /zebra-spaces-signal* { reverse_proxy localhost:8091 } handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1 } }

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:

https://www.unturf.com/zebra-report/zebra-audio.html?signal=wss://turn.example.com/zebra-signal&turncred=https://turn.example.com/turn-cred

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.

▶ open the chat   ▶ open the voice call

zebra report · host your own community · unturf