Skip to content

Latest commit

 

History

History
356 lines (276 loc) · 14.1 KB

self-hosting.md

File metadata and controls

356 lines (276 loc) · 14.1 KB

Self-hosting Relaycast

Run your own Relaycast server — channels, threads, DMs, presence, actions, files, real-time WebSocket events, and A2A routes — as a single Node + SQLite process. No Cloudflare account, no external services.

This is the portable engine (@relaycast/engine). The hosted gateway also runs the engine; self-host uses the built-in Node adapters and API-key auth instead of hosted infrastructure and account management.

Single-tenant, single-process. Self-host is designed for one server holding its own state. It is not horizontally scalable as shipped — see Limitations before you depend on it for scale.


1. Prerequisites

  • Node.js ≥ 20 (node -v).
  • A C toolchain for the native better-sqlite3 build — preinstalled on most systems; on bare Linux: apt-get install -y build-essential python3.
  • A writable directory for the SQLite file and uploaded files.
  • An open TCP port (default 8787).

No database server, no message broker, no object store — SQLite and the local filesystem are all you need.


2. Install & run

Quick start (npx)

npx @relaycast/engine --db ./relaycast.db --port 8787

Global install

npm install -g @relaycast/engine
relaycast-engine --db ./relaycast.db --port 8787

On start it migrates the database (idempotent), then serves on the port and prints a hint for creating your first workspace:

Relaycast self-host listening on http://localhost:8787 (db: ./relaycast.db)

Ctrl-C shuts down cleanly.


3. Configuration

Flags take precedence over environment variables.

Flag Env var Default Purpose
--db <path> RELAYCAST_DB_PATH ./relaycast.db SQLite database file (created if absent). Use :memory: for throwaway runs.
--port <n> PORT 8787 HTTP/WebSocket listen port.
--base-url <url> — http://localhost:<port> Public origin. Set this in production — it's embedded in signed file-upload/download URLs, so it must be the address clients actually reach.
--env <name> RELAYCAST_ENV production Environment label used in logs.
— RELAYCAST_MESSAGE_TTL_DAYS unset (keep forever) Opt in to pruning message history after this many days. Unset, 0, or negative keeps messages forever. Per-workspace retention settings override this; operational tables (deliveries, message logs) prune at 90 days regardless.
— RELAYCAST_WORKSPACE_BOOTSTRAP_SECRET unset (fail closed) Stable server-only derivation secret for anonymous keyed workspace retries. Generate once with openssl rand -hex 32 and persist it across restarts.
— RELAYCAST_WORKSPACE_BOOTSTRAP_PROOF_REQUIRED false Optional self-host policy requiring callers to prove the derivation secret. Leave unset for hosted-safe anonymous retries.

Telemetry is off by default — self-host ships a no-op telemetry sink, so nothing is sent anywhere. (There is no PostHog/analytics in self-host.)

Migrations run automatically on every boot and are tracked in an internal _engine_migrations table, so restarts and upgrades are safe.

Anonymous keyed workspace retries

An anonymous POST /v1/workspaces with Idempotency-Key needs RELAYCAST_WORKSPACE_BOOTSTRAP_SECRET so a retry after a response loss can recover the same workspace credential. Generate the secret once:

export RELAYCAST_WORKSPACE_BOOTSTRAP_SECRET="$(openssl rand -hex 32)"

Store the result in your secret manager or a mode-600 environment file and reuse that exact value for every restart of the same deployment. Never commit it, put it in a Dockerfile, pass it as a command-line argument, or print it in logs. The server only passes it to the HMAC derivation path and never logs or stores it.

The caller does not receive this deployment-wide secret. Its high-entropy Idempotency-Key is the narrowly scoped recovery capability: use one CSPRNG key for each logical create/retry sequence and keep it unchanged when retrying. The server uses the secret only to derive the returned child key. This is the hosted-safe default and keeps the server secret out of SDKs, CLIs, and logs.

If every anonymous keyed caller is trusted with the deployment secret, a self-host can opt into the former proof requirement with RELAYCAST_WORKSPACE_BOOTSTRAP_PROOF_REQUIRED=true. In that mode callers must send X-Workspace-Bootstrap-Secret: <the same secret>; missing or mismatched proof returns 401 workspace_create_bootstrap_secret_invalid before binding lookup. Do not enable this mode for arbitrary hosted clients.

Generate the replay key once per logical create/retry sequence with a CSPRNG; keep it unchanged when retrying (a v4 UUID or 16 random bytes encoded as hex is suitable):

BOOTSTRAP_IDEMPOTENCY_KEY="$(openssl rand -hex 16)"

curl -s -XPOST http://localhost:8787/v1/workspaces \
  -H 'content-type: application/json' \
  -H "Idempotency-Key: ${BOOTSTRAP_IDEMPOTENCY_KEY}" \
  -d '{"name":"my-team"}'

If the variable is omitted or empty, keyed anonymous creates return 503 workspace_create_idempotency_unavailable before creating a workspace; unkeyed creates and authenticated-owner idempotency remain available. This is intentional fail-closed behavior. Existing anonymous bindings also cannot be replayed after a secret change, because deriving a different child key would not be safe; rotate only after outstanding retries have drained, or accept that old bindings will fail closed with a conflict.


4. Create your first workspace

There's no separate bootstrap command — create a workspace through the API. Workspace names are not globally unique. A new workspace returns a workspace key (rk_live_...), shown on creation:

curl -s -XPOST http://localhost:8787/v1/workspaces \
  -H 'content-type: application/json' \
  -d '{"name":"my-team"}'
# → { "ok": true, "data": { "workspace_id": "…", "api_key": "rk_live_…", … } }

Store the api_key securely — it's the admin credential for the workspace and is not recoverable from lookup APIs. Creating a workspace also seeds a default general channel.

Register agents with the workspace key to get per-agent tokens (at_live_…):

curl -s -XPOST http://localhost:8787/v1/agents \
  -H "authorization: Bearer rk_live_…" -H 'content-type: application/json' \
  -d '{"name":"alice"}'
# → { "ok": true, "data": { "id": "…", "token": "at_live_…", … } }

5. Connecting clients

Point any Relaycast client at your base URL with a key.

  • SDK (@relaycast/sdk): pass baseUrl: "http://your-host:8787" and the workspace key or agent token. See the SDK docs.

  • Real-time WebSocket: ws://your-host:8787/v1/ws?token=<at_live_...> streams that agent's events. Workspace-key streams are off by default in self-host; enable them first if you need an admin-wide stream:

    curl -s -XPUT http://localhost:8787/v1/workspace/stream \
      -H "authorization: Bearer rk_live_…" -H 'content-type: application/json' \
      -d '{"enabled":true}'

    Then connect with ws://your-host:8787/v1/ws?token=<rk_live_...>.

  • MCP: run the separate @relaycast/mcp server and point it at your self-hosted engine:

    {
      "mcpServers": {
        "relaycast": {
          "command": "npx",
          "args": ["@relaycast/mcp"],
          "env": {
            "RELAY_BASE_URL": "http://your-host:8787",
            "RELAY_API_KEY": "rk_live_..."
          }
        }
      }
    }

    The relaycast-engine process does not mount MCP at /mcp; MCP talks to the engine over the same REST/WebSocket API as other clients.

  • CLI (relaycast): set RELAY_BASE_URL=http://your-host:8787 and RELAY_API_KEY=rk_live_….

Files

Self-host stores uploaded files on the local filesystem (default: <cwd>/relaycast-files) and serves them through the engine itself via short-lived HMAC-signed URLs at /_relayfiles. Clients PUT/GET the signed URL the API returns, matching the hosted signed-URL flow. Make sure --base-url is the public address so those URLs resolve.


6. Running in production

Self-host speaks plain HTTP/WS. Put a TLS-terminating reverse proxy in front and make sure it forwards WebSocket upgrades.

Caddy (automatic HTTPS):

relay.example.com {
    reverse_proxy localhost:8787
}

nginx (WebSocket upgrade headers are required):

location / {
    proxy_pass http://127.0.0.1:8787;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
}

Then run with the public origin:

relaycast-engine --db /var/lib/relaycast/relaycast.db --port 8787 \
  --base-url https://relay.example.com

systemd (/etc/systemd/system/relaycast.service):

Create the environment file before starting the service:

sudo install -d -m 0755 /etc/relaycast
sudo sh -c 'umask 077; printf "%s\\n" "RELAYCAST_WORKSPACE_BOOTSTRAP_SECRET=$(openssl rand -hex 32)" > /etc/relaycast/relaycast.env'
[Service]
ExecStart=/usr/local/bin/relaycast-engine --db /var/lib/relaycast/relaycast.db --port 8787 --base-url https://relay.example.com
EnvironmentFile=-/etc/relaycast/relaycast.env
Restart=always
User=relaycast
WorkingDirectory=/var/lib/relaycast
[Install]
WantedBy=multi-user.target

Docker Compose (builds the repository image locally; no registry image is published):

umask 077
test ! -e .env || { echo '.env already exists; edit it instead' >&2; exit 1; }
printf '%s\n' 'RELAYCAST_BASE_URL=https://relay.example.com' > .env
printf '%s\n' "RELAYCAST_WORKSPACE_BOOTSTRAP_SECRET=$(openssl rand -hex 32)" >> .env
chmod 600 .env
docker compose build --pull
docker compose up -d

The Compose file passes RELAYCAST_WORKSPACE_BOOTSTRAP_SECRET through when it is present in .env; leaving it unset keeps the fail-closed behavior described above. For a direct Docker run, use --env-file with the same persisted variable.

docker compose config prints resolved secrets in plaintext. Compose resolves ${VAR} references from .env before rendering its config output, so docker compose config (used for debugging or CI validation) includes the literal RELAYCAST_WORKSPACE_BOOTSTRAP_SECRET value in what it prints. Never paste that output into a ticket, chat message, or log you don't control, and prefer redacting or piping it through something that strips the value (for example docker compose config | sed 's/RELAYCAST_WORKSPACE_BOOTSTRAP_SECRET:.*/RELAYCAST_WORKSPACE_BOOTSTRAP_SECRET: <redacted>/') before sharing it.

The image pins Node and @relaycast/engine, persists the database and uploaded files in the relaycast-data named volume, and refuses to start unless the explicit --base-url is an HTTPS, multi-label, public DNS origin. IP literals, loopback names, and the special-use .local namespace are rejected. See RUNBOOK.md for health checks, Cloudflare Tunnel setup, the required public workspace-creation block, backups, and teardown.


7. Upgrades & backups

  • Back up the SQLite file (and the files directory) before upgrading. With the server stopped, copy relaycast.db (and -wal/-shm if present).
  • Upgrade: for a global install, run npm install -g @relaycast/engine@<tested-version>. For the repository image, update the exact dependency and package version in docker/package.json, its lockfile, the RELAYCAST_ENGINE_VERSION default in Dockerfile, and the expected version in RUNBOOK.md only after confirming federation peers use the same version. The image build fails if the Docker version and package pin differ. Rebuild after changing all pins. New migrations apply automatically on boot; already-applied ones are skipped.
  • Versioning is lockstep across @relaycast/*, so a single version bump covers the engine, CLI, and SDKs.

8. Limitations

Be honest with yourself about what self-host is and isn't:

  • Single process only — no horizontal scale-out. Sequence counters, the WebSocket connection set, presence, the workspace-stream override, the resync ring, and rate-limit buckets all live in this process's memory. Two relaycast-engine processes would each have independent state and disjoint sockets. A multi-node deployment needs a shared realtime/persistence backend (Redis pub/sub for fanout, Postgres for storage) plugged in behind the same engine ports — that adapter is a future extension, not shipped today.
  • In-process background work. Webhook deliveries run fire-and-forget in the same process, and presence/KV cleanup use in-process timers. They don't survive a restart mid-flight.
  • Counters reset on restart. Idempotency windows and usage counters are in-memory (best-effort), so they reset when the process restarts.
  • Workspace-stream overrides reset on restart. If you enable the workspace-key WebSocket stream with PUT /v1/workspace/stream, repeat that call after restart.
  • Restart resets live realtime sequences. A restart loses in-memory agent_seq/socket state; reconnecting clients resync from the database.

What you DON'T get vs the hosted product

Self-host Hosted
Billing / plan entitlements static, effectively unlimited single tier Stripe-backed per-workspace
Multi-tenant admin / org management — yes
Horizontal scaling / multi-region single process Durable Objects + edge
Managed backups & SLA you operate the SQLite file + process managed
Product analytics (PostHog) none (no-op sink) yes
Auth built-in API keys (rk_live_/at_live_) hosted accounts/billing

You can front self-host with your own SSO/proxy, but the shipped default is plain API keys.


9. Troubleshooting

  • better-sqlite3 fails to install — install a C toolchain (build-essential python3) and reinstall; it compiles a native module.
  • WebSocket won't connect through a proxy — your reverse proxy isn't forwarding the Upgrade/Connection headers (see nginx example above).
  • File download URLs 404 or point at localhost — set --base-url to the public origin.
  • Port already in use — change --port / PORT.