Self-Hosting a Relay
The relay is a single binary with no required dependencies. All configuration is through environment variables. This page covers three deployment modes, from quick local development to production.
Development (loopback)
The fastest way to get a relay running. State persists to the platform data directory (logged at startup; see Persistent storage). Authentication is always on: every client proves ownership of its DID with a challenge-response signature, and the relay only admits DIDs listed in its authorized-keys file. Allowlist your own identity, then start the relay:
export KUTL_RELAY_AUTHORIZED_KEYS_FILE=~/.kutl/relay_authorized_keys
# your identity is created by `kutl init`/`kutl join`; its DID prints in `kutl status`
did=$(kutl status --format json | jq -er .identity.did) \
&& echo "$did" >> "$KUTL_RELAY_AUTHORIZED_KEYS_FILE"
kutl-relayIf you have no identity yet, an empty allowlist file boots the relay fine. Run kutl init first (registration is open), then append your DID; the file live-reloads.
The relay listens on 127.0.0.1:9100 by default. Clients connect to ws://127.0.0.1:9100/ws, which is also the default relay URL for kutl init and kutl join, so no --relay flag is needed during development. Clients authenticate automatically with the identity kutl creates on first use. There is no login step for self-hosted relays.
There is no auth-off mode: the relay refuses to start without KUTL_RELAY_AUTHORIZED_KEYS_FILE. The file is your operator interface. Edit it like SSH's authorized_keys, one DID per line, and keep it in version control if you like. It is live-reloaded: an appended DID takes effect on the next connection attempt, and the relay does not need a restart. Set KUTL_RELAY_HOST=0.0.0.0 to serve a network-reachable relay (covered below); the default bind is loopback only.
Running in Docker
A multi-arch container image (linux/amd64 and linux/arm64) is published to GitHub Container Registry on each release:
ghcr.io/kutl-io/kutl-relay:latest:latest tracks the newest release. To pin, use a version tag. The :MAJOR.MINOR tag follows patch releases of that line; :MAJOR.MINOR.PATCH is exact. Browse the published tags for the current versions.
The image bakes container-appropriate defaults: KUTL_RELAY_HOST=0.0.0.0 (the container's network namespace already provides the isolation that loopback gives on a host) and KUTL_RELAY_DATA_DIR=/var/lib/kutl (declared as a volume). Authentication is mandatory; the image has no auth-off toggle. Mount an authorized-keys file and point KUTL_RELAY_AUTHORIZED_KEYS_FILE at it; the relay refuses to start without one.
Authorized-keys file mounted read-only, persistent data on a named volume
# your DID is printed by `kutl status`
echo 'did:key:z6Mk...' >> ./authorized_keys
docker run -d --name kutl-relay -p 9100:9100 \
-e KUTL_RELAY_AUTHORIZED_KEYS_FILE=/etc/kutl/authorized_keys \
-e KUTL_RELAY_EXTERNAL_URL=https://relay.example.com \
-v "$PWD/authorized_keys:/etc/kutl/authorized_keys:ro" \
-v kutl-data:/var/lib/kutl \
ghcr.io/kutl-io/kutl-relayAn empty file boots the relay (nobody authorized yet); append DIDs as lines and the relay live-reloads the file. With a bind mount, appends on the host take effect inside the container without a restart. Front the container with a TLS-terminating reverse proxy (Caddy, nginx); see TLS termination below. The image itself does not handle TLS.
docker-compose example
services:
relay:
image: ghcr.io/kutl-io/kutl-relay:latest
restart: unless-stopped
ports:
- "9100:9100"
environment:
KUTL_RELAY_AUTHORIZED_KEYS_FILE: /etc/kutl/authorized_keys
KUTL_RELAY_EXTERNAL_URL: https://relay.example.com
volumes:
- ./authorized_keys:/etc/kutl/authorized_keys:ro
- kutl-data:/var/lib/kutl
volumes:
kutl-data:The image is non-root by default (uid 65532). The data volume must be writable by that uid. With named volumes Docker handles ownership automatically; with bind-mounted host paths, chown 65532:65532 ./data before starting.
Self-hosted with file-based ACL
For a team relay accessible over the network. Authentication is always required; access is controlled by a file listing allowed DIDs (one per line). Data persists to disk.
export KUTL_RELAY_HOST=0.0.0.0
export KUTL_RELAY_PORT=9100
export KUTL_RELAY_EXTERNAL_URL=https://relay.example.com
export KUTL_RELAY_AUTHORIZED_KEYS_FILE=/etc/kutl/authorized_dids.txt
export KUTL_RELAY_DATA_DIR=/var/lib/kutl
kutl-relayEach team member runs kutl status to find their DID, and you append it as a line on the relay host:
echo 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK' >> $KUTL_RELAY_AUTHORIZED_KEYS_FILEA bare DID line grants every space; add scope=, expiry=, or name=, or notes= options to narrow, name, or annotate a grant. A malformed line is dropped whole: the relay fails closed and logs a warning naming the reason, so a typo never widens access. Changes take effect on the next connection attempt, without a relay restart.
Example authorized_dids.txt
# Alice
did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK name=alice
# Bob, and the agent Bob runs
did:key:z6Mkk7yqnGF3YwTrLpqrW6PGsKci7dNqh1CjnvMbzrMerESA name=bob
did:key:z6MkpFQyKT5nqv1kfhjzQpdjMeP2k5s3tXZK8qkCfZ5CzqWm name=bob/rayLines are SSH authorized_keys-shaped: a bare DID grants access to every space. Append scope=<space-id>,<space-id> and/or expiry=<unix-ms> to narrow a grant (name=<text> is what participants address that DID by; a name may be a path such as bob/ray, which --to matches by its full path or any whole trailing part, and notes=<text> is an operator note the relay never serves). A line with a malformed option is dropped entirely, so a typo never widens access.
Persistent storage
The relay always persists its registries to disk using SQLite: space registrations and names, document UUIDs and lifecycle state, invites, and the signal/change record log. KUTL_RELAY_DATA_DIR only chooses where. Unset, the relay uses the platform data directory (e.g. ~/.local/share on Linux) under kutl-relay, and logs the resolved path at startup; the Docker image sets /var/lib/kutl. For a RAM-only relay, point it at a tmpfs.
Live document content is the one thing held in memory: after a relay restart, connected clients re-seed it from their own replicas. Nothing is lost, because the history lives on every client. The SQLite database requires no setup; the relay creates it automatically on first start.
TLS termination
The relay binary does not handle TLS directly. For production, place it behind a reverse proxy (nginx, Caddy, etc.) that terminates TLS and forwards WebSocket connections. Set KUTL_RELAY_EXTERNAL_URL to the public https:// URL so the relay can generate correct URLs for the device auth flow.
Example Caddy reverse proxy
relay.example.com {
reverse_proxy localhost:9100
}Connecting clients
Once the relay is running, clients connect by specifying the relay URL:
# Initialize a new space
kutl init --name my-project --relay wss://relay.example.com/ws
# Join an existing space
kutl join my-project --relay wss://relay.example.com/wsUse wss:// for relays behind TLS, ws:// for unencrypted connections. Clients authenticate automatically with their local DID identity, so there is no login step, but the relay only admits DIDs in its authorized-keys file. Authorize each machine first by appending its DID as a line to that file on the relay host.
Environment variable reference
| Variable | Type | Default | Description |
|---|---|---|---|
KUTL_RELAY_HOST | string | 127.0.0.1 | Bind address. Defaults to loopback so that running kutl-relay with no env vars is safe. Set to 0.0.0.0 for network-reachable deployments. |
KUTL_RELAY_PORT | u16 | 9100 | Bind port |
KUTL_RELAY_NAME | string | kutl-relay-dev | Human-readable relay name (sent in handshake) |
KUTL_RELAY_OUTBOUND_CAPACITY | usize | 512 | Per-connection outbound channel capacity. Slow subscribers are evicted when their channel is full. |
KUTL_RELAY_DATA_DIR | path | platform data dir | Directory for persistent data (registries, invites, signal records). Unset, the relay uses the OS data directory under kutl-relay and logs the path at startup; the Docker image sets /var/lib/kutl. Point at a tmpfs for a RAM-only relay. |
KUTL_RELAY_EXTERNAL_URL | URL | — | Public URL of this relay. Used for the device auth flow (the verification URL prefers KUTL_RELAY_UX_URL when that is set, then this, then http://<host>:<port>) |
KUTL_RELAY_AUTHORIZED_KEYS_FILE | path | — | Required. File of authorized DIDs (one per line; a bare DID grants every space, optional scope=/expiry= options narrow a grant). The relay refuses to start without it and live-reloads it on change; a malformed line is dropped whole with a logged warning (fail closed). Seed it by appending your DID, printed by kutl status. |
KUTL_RELAY_SNIPPET_MAX_DOC_CHARS | usize | 10000 | Max document size for snippet extraction. Set to 0 to disable. |
KUTL_RELAY_SNIPPET_DEBOUNCE_MS | u64 | 2000 | Debounce delay (ms) for snippet computation after edits |
KUTL_RELAY_FLUSH_INTERVAL_MS | u64 | 1500 | Interval (ms) between write-behind flushes of dirty documents to a content storage backend. No effect on a stock self-hosted relay, which holds document content in memory (clients re-seed it); applies to deployments that wire a content backend. |
The table covers the variables a self-hosted deployment typically sets. The relay logs its full resolved configuration at startup.
Don't want to run a relay?
kutlhub.com provides a managed relay with durable storage, a browser editor, and team features. See the Getting Started guide for the self-hosted path.