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 (ephemeral, no auth)
The fastest way to get a relay running. All state is in memory — stopping the relay loses everything. With no env vars set, the relay binds to loopback only and accepts unauthenticated connections from clients on the same machine.
kutl-relayThe 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.
Setting KUTL_RELAY_HOST=0.0.0.0 (or any non-loopback address) automatically flips KUTL_RELAY_REQUIRE_AUTH to true, since serving an auth-disabled relay on a network interface is unsafe. From there you pick a path: provide KUTL_RELAY_AUTHORIZED_KEYS_FILE for production (auth required, listed DIDs allowed), or set both KUTL_RELAY_REQUIRE_AUTH=false and KUTL_RELAY_ALLOW_OPEN_RELAY=true to opt into an open relay (e.g., sim runs across container networking). The relay refuses to start with REQUIRE_AUTH=false on a non-loopback bind without that explicit acknowledgement.
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
ghcr.io/kutl-io/kutl-relay:0.1
ghcr.io/kutl-io/kutl-relay:0.1.5The 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_REQUIRE_AUTH=false. With those defaults alone the relay refuses to start: it would otherwise expose an unauthenticated server, and it can't tell whether you meant to do that. You always pick one of two paths.
Local development (open relay, explicit acknowledgement)
One env var: KUTL_RELAY_ALLOW_OPEN_RELAY=true. Named long enough that nobody types it by accident. The relay logs an "OPEN RELAY" warning at startup and re-emits it every 15 minutes, so the notice survives log rotation in headless setups.
docker run --rm -p 9100:9100 \
-e KUTL_RELAY_ALLOW_OPEN_RELAY=true \
ghcr.io/kutl-io/kutl-relayNetwork-reachable with file-based ACL
Authorized-keys file mounted read-only, persistent data on a named volume. Override the image's REQUIRE_AUTH=false default and provide an allowlist; the relay starts without requiring ALLOW_OPEN_RELAY because it's no longer open.
docker run -d --name kutl-relay -p 9100:9100 \
-e KUTL_RELAY_REQUIRE_AUTH=true \
-e KUTL_RELAY_AUTHORIZED_KEYS_FILE=/etc/kutl/authorized_keys \
-e KUTL_RELAY_DATA_DIR=/var/lib/kutl \
-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-relayFront 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_REQUIRE_AUTH: "true"
KUTL_RELAY_AUTHORIZED_KEYS_FILE: /etc/kutl/authorized_keys
KUTL_RELAY_DATA_DIR: /var/lib/kutl
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 enabled, and 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 add it to the authorized keys file. Changes to the file take effect on the next connection. No relay restart required.
Example authorized_dids.txt
# Alice
did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK
# Bob
did:key:z6Mkk7yqnGF3YwTrLpqrW6PGsKci7dNqh1CjnvMbzrMerESAPersistent storage
When KUTL_RELAY_DATA_DIR is set, the relay persists document state and space registrations to disk using SQLite. The registry database tracks space names, document UUIDs, and lifecycle state. Document content is stored alongside it. Without a data directory, all state lives in memory and the relay reconstructs everything from client operations when they reconnect.
Persistent storage is useful if you want the relay to survive restarts without clients needing to re-sync from scratch. 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.
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_REQUIRE_AUTH | bool | auto | Require valid auth token in handshake. Defaults to false on loopback (127.0.0.1,::1,localhost) and true otherwise. Set explicitly to override. |
KUTL_RELAY_OUTBOUND_CAPACITY | usize | 64 | Per-connection outbound channel capacity. Slow subscribers are evicted when their channel is full. |
KUTL_RELAY_DATA_DIR | path | — | Directory for persistent data (registries, document state). Omit for ephemeral in-memory operation. |
KUTL_RELAY_EXTERNAL_URL | URL | — | Public URL of this relay. Used for device auth flow. Falls back to http://<host>:<port> |
KUTL_RELAY_AUTHORIZED_KEYS_FILE | path | — | File of authorized DIDs (one per line). Authorized DIDs can subscribe to any space. |
KUTL_RELAY_ALLOW_OPEN_RELAY | bool | false | Required when binding non-loopback with auth disabled — the relay refuses to start with that combo unless this is set to true. Used for local dev with the Docker image. Triggers a startup "OPEN RELAY" warning and a recurring reminder every 15 minutes. |
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 |
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.