Reference

Self-hosting

The signaling server is a single Rust (Axum/Tokio) binary backed by Redis. Run it on a $5 VPS, point the SDK at it, and the entire realtime stack is yours: no usage caps, no vendor.

Requirements

Run with Podman / Docker

# Redis
podman run -d --name dendri-redis -p 6379:6379 docker.io/redis:7

# Signaling server
podman run -d --name dendri \
  -p 9876:9876 \
  ghcr.io/afterrealism/dendri-server:latest \
  --host 0.0.0.0 --port 9876 \
  --redis_url redis://host.containers.internal:6379 \
  --enable_relay

A production-grade Podman Quadlet stack (server + Redis + coturn + website, with systemd units) lives in dendri-infrastructure, including Terraform for VM provisioning.

Run from source

git clone https://github.com/afterrealism/dendri-server
cd dendri-server
cargo build --release

# Local development
cargo run -- --host 127.0.0.1 --port 9876 --enable_relay --allow_discovery

# With TLS terminated by the server itself
cargo run -- --host 0.0.0.0 --port 443 \
  --sslkey /path/to/key.pem --sslcert /path/to/cert.pem \
  --enable_relay

Key configuration flags

Every flag can also be set via environment variable; the full reference is in the server repo.

FlagEnvDefaultPurpose
--portPORT9000Listen port.
--host::Bind address.
--keyDENDRI_KEYdendriConnection key namespace (must match the client's key).
--pathPEERSERVER_PATH/URL prefix (must match the client's path).
--redis_urlredis://127.0.0.1:6379State backend.
--enable_relayDENDRI_ENABLE_RELAYfalseTier-4 encrypted WebSocket relay.
--allow_discoveryDENDRI_ALLOW_DISCOVERYfalseEnable the GET /:key/peers listing endpoint.
--turn_secretTURN_SECRETHMAC secret for minting ephemeral TURN credentials.
--turn_serversTURN server URLs handed to clients.
--jwt_secretDENDRI_JWT_SECRETRequire and validate JWTs from clients.
--sslkey / --sslcertTerminate TLS in the server itself.
--corsAllowed CORS origins (repeatable).
--concurrent_limit10000Max simultaneous clients.

HTTP endpoints

MethodPathDescription
GET/healthLiveness probe.
GET/metricsPrometheus metrics.
GET/{base}/:key/idGenerate a peer ID.
GET/{base}/:key/peersList peers (requires --allow_discovery).
GET/{base}/:key/turn-credentialsEphemeral TURN credentials (requires --turn_secret).
GET/{base}/dendriWebSocket upgrade; the primary signaling transport.
GET/{base}/http/sseServer-Sent Events fallback transport.
POST/{base}/http/sendHTTP send (pairs with SSE / long-poll).
GET/{base}/http/pollHTTP long-poll fallback transport.

Per-tenant JWT room ACLs (hosted)

On the hosted tier each tenant can carry its own jwt_secret, so your backend mints short-lived HS256 tokens that gate which rooms your end-users may join. The client passes the token as jwt next to apiKey — on the WebSocket, SSE, and polling transports alike.

Precedence when a connection's API key resolves to a tenant:

  1. The tenant's jwt_secret, when set — the JWT must verify against it.
  2. Otherwise the global --jwt_secret, when set.
  3. Otherwise JWT auth is not enforced for that connection.

Tokens must be HS256 with exp. The rooms claim (array of room IDs) gates ROOM-JOIN — absent means all rooms. The optional tid claim must equal the tenant's id when present.

import jwt from 'jsonwebtoken';

function boardToken(user, roomIds) {
  return jwt.sign(
    { sub: user.id, tid: 't_xxxxxxxxxxxx', rooms: roomIds },
    process.env.DENDRI_TENANT_JWT_SECRET,
    { algorithm: 'HS256', expiresIn: '5m' }
  );
}

Provision the secret via the admin API (--admin_token): set jwt_secret on POST /admin/tenants (returned once, like the API key), or rotate later with POST /admin/tenants/:id/rotate-jwt-secret. GET responses expose only has_jwt_secret — never the secret itself.

TURN with coturn

Without TURN, peers behind symmetric NAT (many mobile carriers, some offices) can't establish direct connections. Run coturn next to the server with a shared HMAC secret:

listening-port=3478
fingerprint
use-auth-secret
static-auth-secret=YOUR_SHARED_SECRET
realm=turn.example.com
--turn_secret YOUR_SHARED_SECRET \
--turn_servers turn:turn.example.com:3478

Clients then opt in with fetchTurnCredentials: true: the SDK fetches short-lived credentials from /turn-credentials before connecting, so no static TURN passwords ever ship in your frontend bundle.

Pointing the SDK at your server

const store = createDendriStore({
	url: "wss://signal.example.com", // your domain
	fetchTurnCredentials: true,
	enableRelay: true,
	signalingTransport: "auto",
});
The client API is identical whether you self-host or use the hosted tier; only the URL (and apiKey) changes. No feature gates.

Scaling and operations

Licensing

ComponentLicenseWhat it means for you
@afterrealism/dendri-client, @afterrealism/dendri-y Apache-2.0 Use, modify, and embed in any app (commercial or closed-source) with attribution.
dendri-server, dendri-infrastructure AGPL-3.0 Run it freely. If you modify it and offer it as a network service, you must publish your modifications under AGPL.

Running the unmodified server for your own product is fine and requires nothing beyond keeping the license notice. If you need to modify the server and keep those changes private while offering it as a service, a commercial license is available from Afterrealism: get in touch.

Deployment questions? Open an issue on GitHub.