Fully self-hosted Obsidian live collaboration — no dependency on relay.md's control plane.
| Component | Port | What it does |
|---|---|---|
relay-server |
8080 | CRDT sync engine (Rust, MIT-licensed fork of y-sweet) |
token-service |
3000 | Issues doc tokens signed by our private key |
pocketbase |
8090 | Auth backend — OAuth login (Google/GitHub/Discord) |
cd ~/relay-self-hosted
docker compose up -dWait for all three containers to be healthy (takes ~30s on first pull):
docker compose ps # all three should show "healthy" or "running"
curl http://localhost:8080/ready # → {"ok":true}
curl http://localhost:3000/health # → {"status":"ok","relay":"http://relay-server:8080"}
curl http://localhost:8090/api/health # → {"code":200,"message":"API is healthy."}Open http://localhost:8090/_/ in your browser and create the admin account.
Then go to Settings → Auth Providers and enable at least one OAuth provider (Google, GitHub, or Discord). You need OAuth credentials from the provider's developer console.
VAULT=~/path/to/your/vault
mkdir -p "$VAULT/.obsidian/plugins/system3-relay"
cp ~/relay-self-hosted/relay-plugin/main.js "$VAULT/.obsidian/plugins/system3-relay/"
cp ~/relay-self-hosted/relay-plugin/manifest.json "$VAULT/.obsidian/plugins/system3-relay/"Then in Obsidian:
- Settings → Community Plugins → turn off Restricted Mode
- Enable "Relay" in the installed plugins list
- Run command palette:
Relay: Register self-hosted Relay Server - Enter:
http://localhost:3000
In the Relay plugin sidebar, log in via PocketBase OAuth. Create a multiplayer folder. Open the same vault on a second device (repeat step 3, same server URL) and log in — edits sync in real time.
The relay.toml private_key is a 32-byte HMAC-SHA256 key in base64url (no padding).
relay-server uses this key to sign and verify document tokens — no relay.md keys configured.
To rotate the key:
./keygen.sh # prints a new [[auth]] block to paste into relay.toml
docker compose restart relay-server token-serviceAfter rotating: active sessions need to re-authenticate (tokens signed with the old key are rejected).
If you want to point at a different host (e.g. a remote server):
cd ~/relay-self-hosted/relay-plugin
RELAY_API_URL=https://relay.example.com node esbuild.config.mjs
# Then re-copy main.js to each vault's plugin directoryObsidian plugin
→ POST /token to token-service (with PocketBase session JWT)
→ token-service validates JWT with PocketBase auth-refresh
→ token-service calls relay-server management API (POST /doc/:id/auth)
→ relay-server issues a doc-scoped CWT signed with private_key
→ token-service returns ClientToken to plugin
→ plugin opens WebSocket to relay-server with that CWT
→ relay-server validates CWT and opens the doc session
The plugin POSTs to ${API_URL}/token with:
POST /token HTTP/1.1
Authorization: Bearer <pocketbase-session-jwt>
Content-Type: application/json
{
"docId": "<document-uuid>",
"relay": "<relay-id>",
"folder": "<folder-id>",
"device": "<device-id>" // optional
}Expected response (ClientToken):
{
"url": "ws://localhost:8080/doc/",
"baseUrl": "http://localhost:8080",
"docId": "<document-uuid>",
"folder": "<folder-id>",
"token": "<HMAC-SHA256 CWT signed by relay.toml private_key>",
"authorization": "full",
"expiryTime": 1234567890000
}- Replace
localhostURLs with your server's hostname inrelay.toml([server] url) and rebuild the plugin - Use Cloudflare R2 or S3 for storage instead of local filesystem (set
[store] type = "s3"in relay.toml) - Put token-service and PocketBase behind a reverse proxy (nginx/Caddy) with TLS
- Rotate the signing key and store it in a secret manager (not in relay.toml plaintext)
- Enable PocketBase OAuth providers in the admin UI
relay.toml— relay-server config (our key, filesystem storage)docker-compose.yml— full stack (relay-server, token-service, pocketbase)keygen.sh— generates a new signing keydata/— local document storagepb_data/— PocketBase databaserelay-plugin/— forked Obsidian plugin (patched build constants, built main.js)token-service/— Node.js token microservice source