shoal
self-hosted sync server for local-first apps
one small rust binary stores an append-only log of end-to-end encrypted operations per user. clients merge, the server only stores and orders ciphertext.
properties
- end-to-end encrypted. payloads are xchacha20-poly1305 ciphertext. the server never holds a decryption key.
- no accounts. identity is an ed25519 keypair derived from a 12-word bip39 mnemonic. the same words on a new phone restore everything.
- app-agnostic. apps are namespaced by a
collectionstring. one server instance syncs any number of apps. - offline-first. the server is an availability convenience. apps work fully without it and reconcile when it returns.
- small. axum + sqlite, one binary, one file of state.
how it works
clients append full-state record ops to a local outbox and push them in signed batches. the server assigns each op a per-user sequence number. clients pull ops past their cursor and merge with per-table strategies (last-writer-wins on a hybrid logical clock, or append-only). an sse endpoint pokes connected clients when new ops land.
the full wire format, key derivation, auth scheme, and storage schema are in protocol.md.
run
docker compose up -d --build
or pull the prebuilt image:
ghcr.io/emerson-d-lopes/shoal:latest. or bare:
cargo run --release.
api
| endpoint | purpose |
|---|---|
POST /v1/ops | push a batch of encrypted ops (idempotent by op_id) |
GET /v1/ops?since=N | pull ops past a cursor, optional collection filter |
GET /v1/poke | sse stream, emits when new ops arrive for the caller |
GET /healthz | liveness |
all endpoints except /healthz require ed25519 request
signatures.
clients
a typescript client is at shoal-client, used by mnemonic and habit-tracker. a kotlin client for android is in progress.