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

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.

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.5

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

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

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

Each 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:z6Mkk7yqnGF3YwTrLpqrW6PGsKci7dNqh1CjnvMbzrMerESA

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

Use wss:// for relays behind TLS, ws:// for unencrypted connections.

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_REQUIRE_AUTHboolautoRequire 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_CAPACITYusize64Per-connection outbound channel capacity. Slow subscribers are evicted when their channel is full.
KUTL_RELAY_DATA_DIRpathDirectory for persistent data (registries, document state). Omit for ephemeral in-memory operation.
KUTL_RELAY_EXTERNAL_URLURLPublic URL of this relay. Used for device auth flow. Falls back to http://<host>:<port>
KUTL_RELAY_AUTHORIZED_KEYS_FILEpathFile of authorized DIDs (one per line). Authorized DIDs can subscribe to any space.
KUTL_RELAY_ALLOW_OPEN_RELAYboolfalseRequired 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_CHARSusize10000Max document size for snippet extraction. Set to 0 to disable.
KUTL_RELAY_SNIPPET_DEBOUNCE_MSu642000Debounce 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.