Hosting a lofi app on your own sync node
lofi-node is the first-class way to self-host the sync backend for a
lofi app: the browser keeps speaking Jazz's protocol
(adapter: "jazz" untouched); only the server URL changes. This walkthrough was validated end to
end against lofi's reference app (apps/) with real Chromium clients.
1. Host a node
Install once (deno install -g -A -n lofi-node jsr:@nzip/), run ad hoc with
dx -A jsr:@nzip/, or download a compiled binary from the GitHub releases. Then:
lofi-node init --dir ./data --port 4802 --public-url http://192.168.1.10:4802
lofi-node start --dir ./data
New inits are ticket-gated: only requests carrying an issued app-ticket secret reach Jazz (the
embedded Jazz server binds loopback-only; lofi-node's access gate owns the public port). --open
opts out for dev setups. data/ holds the app id and the admin secret you'll need below.
Storage choice
--storage-path / puts the SQLite store on any mounted location (NAS, synced volume);
--memory keeps everything ephemeral. The path is probed writable at boot — an unwritable location
fails fast by name. jazz-napi supports exactly SQLite-directory and memory today; for cloud off-site
durability, replicate the SQLite file (Litestream-style to S3, or a snapshotting volume). The
storage.type discriminator in config is the seam future providers slot into.
Issue an app ticket
lofi-node ticket issue --dir ./data --label phone
# → lofisync1.eyJ2IjoxLCJhcHBJZCI6… (shown once; secret is never stored)
lofi-node ticket list --dir ./data
lofi-node ticket revoke <id> --dir ./data # live sockets close with 4001
The user pastes the ticket into their lofi app (tell them to let their password manager save it — the secret is shown once and the node keeps only a hash). The app never stores a ticket in cleartext: a sync ticket persists as a sealed record under a device-bound key and its URL becomes the sync server; a provision ticket is split first through the scope-down exchange — the app stores a derived sync ticket and keeps the provision original passkey-sealed or memory-only. Format and app-side flow: app-ticket.md.
2. Get the app's schema into the store
A store with no deployed schema is unusable — client writes hang (preflight with
GET <ticket.url>/, see app-ticket.md). Two paths publish a schema:
Primary — runtime provisioning via a provision ticket: issue
lofi-node ticket issue --provision --label app-setup and hand it to the app. On enrollment the app
splits it through the scope-down exchange (everyday transport rides a derived sync ticket; admin
capability unlocks through a passkey ceremony when provisioning actually runs). The app (or any
jazz-tools deploy caller) uses the ticket URL as serverUrl with a placeholder admin secret — the
gate strips inbound admin headers and injects the node's own for provision-scoped requests, so the
real secret never leaves the node. Slice-by-slice merge deploys (createTables migrations, union
permissions) flow through the same ticket; old-hash clients keep syncing across a merge.
Fallback — operator-side deploy from the app directory, using the same schema-project deploy the
lofi dev flow uses (also easiest via a provision ticket URL as serverUrl; the admin secret
argument can then be a placeholder):
// deploy-schema.ts (run: deno run -A deploy-schema.ts)
import { deploy } from "./node_modules/.deno/jazz-tools@2.0.0-alpha.53/node_modules/jazz-tools/dist/dev/catalogue-project.js";
await deploy({
serverUrl: "http://127.0.0.1:4802",
appId: "<app id from config.json>",
adminSecret: "<admin secret from config.json>",
schemaDir: "./src",
});
The schemaDir-project deploy is not yet a public jazz-tools export — a
lofi-node deployCLI verb wrapping this is a natural follow-up once jazz-tools exposes it (or lofi's provision command grows a self-host mode).
3. Build the app against the node
JAZZ_APP_ID=<app id> JAZZ_SERVER_URL=<ticket url> deno task build
deno task preview
JAZZ_SERVER_URL must be http(s) — lofi's preflight rejects ws URLs; the browser client derives the
WebSocket endpoint itself (…/, preserving the ticket's / base path). On
an open-mode node use the node URL directly.
Budget for the app shell's weight when you serve it: the Jazz WASM engine is ~9.8 MB uncompressed
(~3 MB gzip), and lofi's build prints the measured shell total on every run. Serve the static app
with compression — precompress at deploy time (brotli -k, gzip -k9 over dist/) and let the
file server prefer the precompressed variants (Caddy file_server with precompressed br gzip,
nginx gzip_static on;/brotli_static on;), or at minimum enable on-the-fly compression for
.wasm and .js. The service worker precaches the shell once per app version, so the cost is a
cold install and each update, not every visit. Beyond a trusted LAN, front the gate with TLS;
installed PWAs require a secure origin. (Once lofi ships its runtime serverUrl override, the
build-time env becomes unnecessary — the app enrolls the ticket at runtime instead; see
app-ticket.md.)
Validation status (2026-07-18, jazz-tools 2.0.0-alpha.53)
- Reference app builds, boots, and serves against a lofi-node backend; two real Chromium clients
(lofi's
createTwoClientFixture) load the app, hydrate the store, go offline, and edit independently. - lofi's opt-in browser convergence gate (
tests/) passes against lofi-node: two real Chromium clients with the backup-and-sync election performed, concurrent offline edits, reconnection, full bidirectional convergence — identical results against lofi's own managed dev server. Browser clients log one benignconvergence_e2e_test.ts CatalogueWriteDeniedwarning at boot on any server; it does not affect sync. Headless two-client convergence also passes — seetests/.convergence_test.ts
Pairing two homes
On the second node: lofi-node pair <ticket from the first> (or SyncNode.pair(ticket) at
runtime). Catalogue reads/writes and sync then flow to the peer over iroh — deploying the schema to
either node lands on the root through the tunnel's HTTP proxy.