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-relay

If 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-relay

An 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-relay

Each 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_FILE

A 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/ray

Lines 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/ws

Use 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

VariableTypeDefaultDescription
KUTL_RELAY_HOSTstring127.0.0.1Bind 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_PORTu169100Bind port
KUTL_RELAY_NAMEstringkutl-relay-devHuman-readable relay name (sent in handshake)
KUTL_RELAY_OUTBOUND_CAPACITYusize512Per-connection outbound channel capacity. Slow subscribers are evicted when their channel is full.
KUTL_RELAY_DATA_DIRpathplatform data dirDirectory 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_URLURLPublic 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_FILEpathRequired. 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_CHARSusize10000Max document size for snippet extraction. Set to 0 to disable.
KUTL_RELAY_SNIPPET_DEBOUNCE_MSu642000Debounce delay (ms) for snippet computation after edits
KUTL_RELAY_FLUSH_INTERVAL_MSu641500Interval (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.