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

# Build the server image from the public source
git clone https://codeberg.org/afterrealism/dendri
cd dendri
podman build -t dendri-server:local .

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

For production, run the same image under systemd (Podman Quadlets) next to Redis and coturn; the Dockerfile in the server repo is the deployment unit. TURN with coturn is covered below.

Run from source

git clone https://codeberg.org/afterrealism/dendri
cd dendri
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_url—redis://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_SECRET—HMAC secret for minting ephemeral TURN credentials.
--turn_servers——TURN server URLs handed to clients.
--jwt_secretDENDRI_JWT_SECRET—Require and validate JWTs from clients.
--sslkey / --sslcert——Terminate TLS in the server itself.
--cors——Allowed CORS origins (repeatable).
--concurrent_limit—10000Max 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 (multi-tenant)

In a multi-tenant deployment 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",
});
One server, no feature gates. The open-source server on this page is the whole product: every documented feature — relay, TURN minting, multi-tenancy — runs in the binary you deploy.

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 modify the server and offer it as a network service, publish those modifications under AGPL — that's the only condition.

Deployment questions? Open an issue on Codeberg.