Excalidraw with a self-hosted cloud. Drawings live in the browser (IndexedDB), every change syncs through a shoal server as end-to-end encrypted ops, and a new device restores everything from the 12-word recovery phrase. The server never sees a drawing.
Built on @excalidraw/excalidraw (MIT) and shoal-client. Live at scalidraw.edfl.dev.
The diagram is a scalidraw drawing. It was made in the app and synced through the pipeline it describes.
| Component | Where it runs | Role |
|---|---|---|
| scalidraw app | Vercel, static files at scalidraw.edfl.dev | Serves the UI to every device. Holds no data and no secrets. |
| Browser client | Each device | Owns the data: excalidraw editor, Dexie/IndexedDB storage, encryption, merge logic. |
| shoal server | Docker container, published with Tailscale Funnel | Stores and orders encrypted ops. Cannot read them. |
The app is a viewer onto local data. A device with the app and no server configured is fully functional, sync is an add-on.
One shoal collection, scalidraw, three record kinds:
| Record | Content | Merge |
|---|---|---|
drawing/<id> |
title, updatedAt, or {deleted:true} |
last writer wins |
el/<drawingId>/<elementId> |
one excalidraw element, full JSON | last writer wins per element |
file/<fileId> |
one embedded image (BinaryFileData) | content-addressed, written once |
Per-element records are the load-bearing choice. A whole scene as one record
would exceed the op payload cap as soon as an image lands and would turn
every concurrent edit into a whole-drawing conflict. Excalidraw is built for
element granularity: every element carries a stable id, a version
counter, a versionNonce, and isDeleted tombstones, and element order is
a fractional index stored on the element itself. Ordering therefore syncs
with the elements and needs no record of its own. Per-device view state
(zoom, scroll, selection) is deliberately not synced.
src/Editor.tsx connects excalidraw to the sync engine in both directions.
Local writes: excalidraw fires onChange constantly, including for pure
selection changes. A trailing 400 ms debounce takes a snapshot, diffs each
element's (version, versionNonce) against a map of the last observed
state, and writes only changed elements, to Dexie and to the outbox in one
pass. A selection-only change diffs to nothing and costs nothing.
Remote changes: the sync engine applies incoming records to Dexie and pings
the editor, which rebuilds the scene from Dexie and calls updateScene.
The observed-state map is updated before the push, so the onChange that
updateScene triggers diffs to nothing. That ordering is the echo guard.
Renames commit once per rename, one op, rather than one per keystroke.
Everything derives from a 12-word BIP39 phrase, client-side:
seed = BIP39-seed(phrase)
sign_key = HKDF-SHA256(seed, info="shoal/v1/sign") ed25519, request signatures
enc_key = HKDF-SHA256(seed, info="shoal/v1/enc") XChaCha20-Poly1305, payloads
user_id = base64url(ed25519 public key)
Payloads are encrypted before leaving the device, with the record's collection and id bound in as AAD so the server cannot move an op between records. Every request carries an ed25519 signature over method, path, timestamp, and body hash. There are no accounts, no cookies, and no sessions. The same phrase on a new device reproduces the keys and restores the whole library. The phrase cannot be recovered from anywhere.
Push: the outbox drains to POST /v1/ops in signed batches. The server
assigns each op a per-user sequence number.
Pull: the client asks for everything past its cursor
(GET /v1/ops?since=N), applies through the LWW gate, advances the cursor.
The cursor is a position in one specific server's log, so the client resets
it whenever the server URL or identity changes.
Poke: GET /v1/poke is a long-lived SSE stream, a doorbell only. When any
of the user's devices pushes, the server emits a tiny event and listening
clients pull. Data never travels on the stream, so a dropped stream loses
nothing. The stream is read via fetch because EventSource cannot send
the signature headers.
Offline: writes queue in the outbox inside the app's own IndexedDB. A dead server changes nothing about the app. The live loop reconnects with exponential backoff and also syncs on tab refocus and on the browser's online event.
Payloads are ciphertext. Metadata is not: record ids (so the el/ and
drawing/ structure), write timestamps, op counts, payload sizes, and the
user's public key are visible to the server operator. Contents, titles
included, are inside the encrypted payload. The full threat model is in the
shoal repo's PROTOCOL.md.
- Not realtime co-editing. Two devices on one drawing converge per element by last writer wins, which suits one person on many devices. Live cursors would need excalidraw-room, orthogonal to shoal.
- No key rotation. A leaked phrase means a new identity and a re-push from a healthy device.
- The client's
maxPayloadBytesand the server'sSHOAL_MAX_PAYLOAD_BYTESmust match, both sides currently 4 MiB.
pnpm install
pnpm dev # local dev
pnpm test # unit tests for the sync bridge and list logic
pnpm build # static site in dist/Point it at a shoal server from the settings page (gear icon): server URL plus recovery phrase, empty phrase generates one.
docker compose up -d --build in the shoal repo. The
docker-compose.override.yml sets:
SHOAL_MAX_PAYLOAD_BYTES: "4194304" # images ship as single ops; matches MAX_PAYLOAD_BYTES in src/sync.ts
SHOAL_ALLOWED_PUBKEYS: "<your public key>"
SHOAL_MAX_TOTAL_OPS: "2000000"Publishing: tailscale funnel --bg 7420 gives the container a public HTTPS
URL on the machine's ts.net name. Only the server machine runs Tailscale,
clients just use the URL. The allowlist means a valid signature from any
key not pinned gets 403 before touching storage.
Server URL is per-device configuration. Devices elsewhere use the funnel
URL. Browsers on the server machine itself use http://127.0.0.1:7420,
because Chrome's secure DNS resolves the machine's own funnel name to the
public ingress and that hairpin path hangs from behind the same NAT.
Manual static deploy of the local build. The link step is required every
time, rebuilding wipes dist/.vercel:
pnpm build
cd dist
pnpm dlx vercel@latest link --yes --scope studio-labs --project scalidraw
pnpm dlx vercel@latest deploy --prod --yespnpm test covers the pure logic: record id round-trips, the element
differ and its echo idempotence, search, pagination, and stats helpers.
End-to-end behavior (two-device sync, offline drain, image payloads,
restore from phrase) was verified against a live server and browser.
