Skip to content

Hub Setup

xNet works fully peer-to-peer — no server required. A Hub improves availability and adds services that are hard on mobile devices:

Without HubWith Hub
Sync only when both peers are onlineSync anytime — Hub bridges the gap
No backupEncrypted backup (zero-knowledge)
No full-text search across devicesServer-side FTS5 search
P2P onlyP2P when possible, Hub when not

The Hub never sees your plaintext data. It stores encrypted updates and relays them to your devices.

flowchart LR
  A[Device A] <-->|P2P| B[Device B]
  A <-->|Hub Relay| H[Hub]
  B <-->|Hub Relay| H

The fastest way to get a Hub running. No Docker, no SSH, no TLS setup.

Deploy on Railway

What happens:

  1. Railway clones the xNet repo
  2. Builds the Hub from its Dockerfile
  3. Creates a persistent volume for SQLite + blobs
  4. Gives you a URL: hub-xyz.up.railway.app

Cost: $0-2/month for a personal Hub (covered by Railway’s $5 Hobby credit).

Your Hub is live. Copy the URL and configure your app:

import { XNetProvider } from '@xnetjs/react'
import { XNetDevToolsProvider } from '@xnetjs/devtools'
;<XNetProvider
config={{
hubUrl: 'wss://hub-xyz.up.railway.app'
}}
>
<XNetDevToolsProvider>
<App />
</XNetDevToolsProvider>
</XNetProvider>
PlatformBest forTypical costNotes
RailwayOne-click, lowest friction$0-2/moIdeal default for personal Hubs
Fly.ioAuto-suspend, global edge$2-6/moSuspend/resume adds ~2s wake
VPSFull control$4-5/moYou manage TLS + upgrades

Best for auto-suspend cost savings or multi-region experiments.

Terminal window
# Install Fly CLI
curl -L https://fly.io/install.sh | sh
fly auth login
# Deploy
cd packages/hub
fly launch --no-deploy
fly volumes create xnet_hub_data --size 1 --region sjc
fly deploy

Cost: ~$2-6/month depending on usage. Machines can auto-suspend when idle.

For multi-region (future), add additional regions and volumes, then enable hub federation for query routing.

VariableDefaultDescription
PORT4444Listen port (Railway injects this)
HUB_DATA_DIR./xnet-hub-dataSQLite + blob storage directory
HUB_LOG_LEVELinfoLog verbosity: debug, info, warn, error
HUB_PUBLIC_URLPublic URL for discovery + federation
RAILWAY_VOLUME_MOUNT_PATHAuto-set by Railway when a volume is attached
XNET_DIAGNOSTICS_URLOpt-in: upstream sink for scrubbed crash reports (see below)
XNET_DIAGNOSTICS_SECRETOpt-in: shared secret for diagnostics sharing
XNET_DIAGNOSTICS_INBOXopenCrash inbox mode: open, authed, or off
XNET_SHARE_CRASH_COUNTSOpt-in: tee fingerprint-level crash counts upstream

Your hub is your crash console (default on)

Section titled “Your hub is your crash console (default on)”

Every hub runs a first-party diagnostics inbox: your deployment’s own clients report crashes to POST /diagnostics/ingest on your hub, and the reports go nowhere else. The quarantine lives in its own diagnostics.db (8 KB per report, deduplicated by fingerprint, 30-day retention for handled reports, hard row cap), and the operator drains it from the app — Settings → Privacy & Diagnostics → Import reports — into a Diagnostics Space where the workbench is the triage console (Inbox / By release / By fingerprint saved views, a new → acked → fixed → released status field, comments).

The ingest is rate-limited per client IP and accepts only allowlisted, length-bounded, twice-scrubbed fields. Set XNET_DIAGNOSTICS_INBOX=authed to require authentication for it on hubs exposed to the open internet, or off to disable the inbox entirely.

Escalation to xNet is a separate, layered choice — three switches, each off by default:

  1. Send one report — from the console, “Send to xNet” previews the exact payload and forwards it through the sharing route below. Requires sharing to be configured; without it, the route does not exist.
  2. Share crash countsXNET_SHARE_CRASH_COUNTS=on (plus the sharing config below) tees fingerprint-level data upstream on each automatic crash: error name, release, surface, grouping hash, and count. Never the message, stack, breadcrumbs, or any identifier.
  3. Let xNet help debug — grant xNet’s support identity time-boxed, read-only access to your Diagnostics Space from the same settings panel. It expires on its own and can be revoked in one click.

Diagnostics sharing (opt-in, off by default)

Section titled “Diagnostics sharing (opt-in, off by default)”

Your hub keeps its own errors to itself. If you’d like xNet to help debug your hub, set both XNET_DIAGNOSTICS_URL and XNET_DIAGNOSTICS_SECRET to enable the POST /diagnostics/report route, which forwards scrubbed, content-free crash reports upstream — the sender’s DID is hashed (never sent raw) and document content is never included. Leave them unset (the default) and nothing is forwarded; GET /diagnostics/health simply reports sharing: false.

The upstream sink is xNet Cloud’s first-party POST /diagnostics ingest (exploration 0315): reports land in a quarantine and are drained into debug-report nodes in the operator’s own xNet workspace, where the workbench is the triage console — filterable tables, a new → acked → fixed → released status field, and comments. xNet does not use a third-party error service such as Sentry. The client keeps a dormant, vendor-neutral Sentry seam (no @sentry/* SDK is installed and no DSN is minted), so if you self-host a Sentry-compatible backend you can point that seam at it — Bugsink (single container, SQLite) or GlitchTip (MIT, Sentry-SDK compatible) are the proven small-footprint options — but nothing ships enabled.

FlagDescription
--portListen port
--dataData directory
--no-authDisable UCAN authentication
--storageStorage backend: sqlite, memory
--public-urlPublic hub URL for discovery
--max-connectionsMax concurrent WebSocket clients
--max-blob-sizeMax backup blob size
--awareness-ttlAwareness TTL in ms
--discovery-ttlPeer discovery TTL in ms
import { XNetProvider } from '@xnetjs/react'
import { XNetDevToolsProvider } from '@xnetjs/devtools'
;<XNetProvider
config={{
hubUrl: 'wss://your-hub.example.com'
}}
>
<XNetDevToolsProvider>
<App />
</XNetDevToolsProvider>
</XNetProvider>
const manager = new SyncManager({
hubUrl: 'wss://your-hub.example.com'
})
EndpointPurpose
GET /healthJSON status, uptime, platform metadata
GET /metricsPrometheus metrics
EndpointPurpose
PUT /backup/:docIdUpload encrypted backup
GET /backup/:docIdDownload encrypted backup
GET /backupList backups for your DID
PUT /files/:cidUpload content-addressed file
GET /files/:cidDownload file
POST /schemasPublish schema
GET /schemas/resolve/:iriResolve schema
POST /dids/registerRegister peer discovery
GET /dids/:didResolve peer

The Hub stores encrypted blobs. Your app encrypts before upload.

EndpointPurpose
PUT /backup/:docIdUpload encrypted backup
GET /backup/:docIdDownload encrypted backup
GET /backupList backups for your DID

Queries run locally first, with Hub results filling in the gaps:

const results = await queryClient.search('budget', {
federate: true,
limit: 20
})

Moving between platforms is straightforward:

  1. Stop the old Hub (SIGTERM flushes SQLite)
  2. Copy the data directory (/data or your HUB_DATA_DIR)
  3. Start the new Hub with the same data dir
  4. Update your app’s hubUrl

Railway → VPS/Fly.io is just a volume export + upload. Fly.io → Railway is the same process in reverse.