My apps are local-first on purpose: flashcards, a habit tracker, a drawing tool. Data lives on the device, there are no accounts, nothing phones home. The cost shows up when you get a new laptop, or want the same drawings on two machines. Shoal fixes that: one self-hosted Rust server that syncs all of them without being able to read any of it.
The design bet is that the server should know as little as possible. It turns out “as little as possible” is very little, and that the things it cannot know are exactly the things that would otherwise couple it to every app.
the server stores an ordered log of opaque blobs
A client pushes operations. Each op is a collection name, a record id, and an encrypted payload. The server assigns every accepted op a per-user sequence number, appends it to a log, and serves “everything after cursor N”. That is the entire feature set. It never parses a payload, never merges anything, never knows that a record is a flashcard.
Everything interesting happens in clients. Each app maps its tables onto ops: full record state, encrypted, one op per change. Clients pull the log past their cursor and fold it into their local database with their own merge rules. Last-writer-wins uses a hybrid logical clock encoded as twelve hex digits of milliseconds, four of counter, and eight of node id, so a plain string comparison is a causal comparison.
The payload is XChaCha20-Poly1305 ciphertext, and the associated data bound into the cipher is the collection and record id. So a server cannot silently move an op from one record to another. The ciphertext only decrypts at the address it was written to.
identity is twelve words
There is no registration. A 12-word phrase derives an ed25519 key that signs every request, and a separate symmetric key that encrypts payloads. The signature covers the method, the path with query, a timestamp, and a hash of the body, with a five-minute skew window. The first valid signed push creates the user. The server stores the public key and nothing else about you. The same twelve words on a new machine restore everything.
The consequences are stated in the protocol document rather than hidden. There is no forward secrecy, because the encryption key is derived once and never rotates. Losing the phrase is permanent data loss. A curious operator can still see which apps you run (the collection name), roughly how big each record is, when you write, and a stable pseudonymous key. A malicious operator can withhold ops or report a stale head, and there is no hash chain to detect it. Those are the trade-offs, written down so that nobody discovers them after depending on the opposite.
the two hard parts
Compaction. A log that only grows is fine until a drawing tool writes an op per element per edit. Compaction lets a client ask the server to drop superseded ops for a record. Two rules make it sound. It is scoped to one collection and capped at the caller’s own pull cursor, so the server can never discard something the client has not merged yet. It also relies on the invariant that every op carries full record state, so the newest op for a record is the record. The protocol document says plainly that compaction is unsound for an append-only collection, and that the server cannot tell the two kinds apart. That is a client’s responsibility, and it is documented as one rather than guarded against with logic the server would need to understand payloads to enforce.
The stored head. After compaction there are gaps in the sequence. MAX(seq) overstates the head, and COUNT(*) becomes a scan over every op on every push. So head and op_count are stored on the user row and maintained on write, rather than derived. A small denormalization, and the kind that is easy to get wrong if it is added later instead of designed in.
what it costs and what it buys
The server is under two thousand lines of Rust in five files, with 48 tests between unit and integration. Axum, SQLite in WAL mode, one binary, published as a container image on every push to main. Payloads are capped at 256 KiB per op and 16 MiB per request, batches at a thousand, and requests at 120 a minute per user.
Three apps sync through one instance. Adding the third took no server change. The server I deployed for the flashcards app served the drawing tool with no change, because it had never known what a flashcard was in the first place. A server that cannot read the data has nowhere to put the coupling, and that turns out to be the same decision as end-to-end encryption.