Sync Without the Sync Subscription
I wanted Obsidian notes that sync between my laptop, desktops, and phone, live, without paying for Obsidian Sync and without handing the vault to someone else's cloud. yaos1 does the live part well. It's a CRDT sync plugin with a small server, and the server is written for Cloudflare Workers.
I don't want my notes on Cloudflare either. The devbox has a data disk with a few terabytes free, and everything that talks to it is already on the tailnet. So I wanted to know if a Cloudflare Worker would run on the devbox, with the devbox's disk standing in for Cloudflare's storage.
It does. Cloudflare publishes the runtime that runs Workers as workerd, and nearly everything yaos needs comes with it. The one piece that doesn't is R2, Cloudflare's object storage, and yaos uses only five R2 methods. A shim about 200 lines long covers them.
What the Server Actually Needs
Before building anything I read server/wrangler.toml and grepped server/src for every binding. The list is short.
The Worker itself handles HTTP and WebSocket on every route: claim, tickets, the sync socket, blobs and snapshots. It works on workerd as-is. So does WebSocket hibernation, which yaos uses for idle sync rooms.
There are two SQLite-backed Durable Objects. VaultSyncServer holds each vault's Yjs doc, and ServerConfig holds the claimed token hash. On workerd they need enableSql = true and localDisk storage, which puts the SQLite files in a directory I pick.
Alarms aren't used. A code comment says they avoided them to save free-tier writes.
The R2 bucket, YAOS_BUCKET, is optional and holds attachments and daily snapshots. workerd has no local backend for it, and that's the gap.
Markdown sync works with no bucket at all. R2 only adds attachments and snapshots, and I wanted both. The R2 calls are get, put, head, list and delete, all in two files (snapshot.ts and routes/blobs.ts). It doesn't use multipart uploads, range reads or presigned URLs, because the plugin sends every byte through the Worker. So the replacement can be small.
Live Obsidian sync from a server written for Cloudflare Workers, run unmodified on the devbox.
- yaos itself is unmodified
- Durable Objects on workerd
- `YAOS_BUCKET` is a shim over Garage
- Claimed from a tailnet device before anyone gets the URL
The Plan
I'd run yaos itself unmodified and add only two small pieces of code. One is a wrapper entrypoint that hands yaos a fake R2 bucket. The other is that fake bucket. It takes R2 calls and makes S3 calls to Garage. The rest is stock. workerd runs the Worker, Garage stores the blobs, and Caddy fronts it on the tailnet the way it already fronts the other services.
Sync state never touches Garage. The Durable Objects write their SQLite straight to the data disk, and only attachments and snapshots go through the shim.
yaos on workerd
wrangler normally does two jobs: it bundles the Worker, then uploads it. I only needed the bundle. I built it with esbuild, using the same import conditions wrangler uses, and workerd loads it from a Cap'n Proto config file.
In the config, I swapped each Cloudflare piece for a local one. Durable Object storage is a writable disk service backed by a directory. The S3 store is an external service that only this Worker can reach. Secrets come from the container's environment through fromEnvironment, so none of them end up in the image.
The Dockerfile has two stages. The build stage fetches yaos at a pinned commit, runs npm ci in server/, installs workerd from npm, and bundles. The runtime stage is debian:trixie-slim with only the workerd binary, the config and the bundle. I pinned workerd to 1.20261019.1, about two weeks old when I built this, and not the release from that morning.
One wall I planned for from the start, so I never hit it. It's still the first one you'd run into.
A wrangler-built Worker on bare workerd, with Durable Objects on local disk.
- Bundle with esbuild
- Durable Objects with SQL
- Durable Object storage on local disk
- Private backends as `external` services
- Secrets via `fromEnvironment`
- workerd pinned
A Stand-In for R2
R2 speaks the S3 protocol on Cloudflare's side, so any S3-compatible store can hold the data. I picked Garage. It's a single Rust binary, it runs fine as one node, and it keeps metadata in SQLite.
The store is the easy half. The hard half is that yaos expects an R2Bucket object on env.YAOS_BUCKET, not an S3 client. I didn't want to patch yaos, because then every upstream release means rebasing a patch. So the shim impersonates an R2Bucket. Each of the five methods makes one S3 request, signed with SigV4 by aws4fetch and sent through the service binding.
get, head and delete are plain signed requests. put copies httpMetadata into headers and then does a HEAD request, because R2's put returns the stored object and S3's doesn't. list is the only method with any real logic in it. It calls ListObjectsV2 and pulls Key, Size, ETag, IsTruncated and NextContinuationToken out of the XML with regexes, since workerd has no DOMParser.
To get the shim in front of yaos without touching yaos, I wrote a wrapper entrypoint. It re-exports yaos's Worker and both Durable Object classes, and gives each one an env with the bucket added. It has to wrap the Durable Objects too, because yaos writes snapshots from inside VaultSyncServer (this.env.YAOS_BUCKET). Wrapping only the fetch handler would leave the snapshots with no bucket.
Five R2 methods mapped to S3, plus the wrapper that injects them, so yaos runs without a patch.
- `get`, `head`, `delete`
- `put`
- `list`
- A wrapper entrypoint
Two Containers on the Data Disk
The stack has two services. Only one port is published, 127.0.0.1:8787, and only Caddy uses it. Garage's S3 port isn't published at all, so nothing outside the stack's Docker network can talk to it. All data goes to the data disk under /srv/data/yaos. Both containers run as my uid, so the files on disk belong to me and not to root.
The Garage config is short. It's one node with replication_factor = 1 and a SQLite metadata store with snapshots every six hours. The RPC secret and admin token aren't in the file; Garage reads them from the environment.
A fresh Garage won't store anything until it has a layout. scripts/setup.sh does the first-run steps in order, and each step checks first, so running it again is harmless. It generates GARAGE_RPC_SECRET and GARAGE_ADMIN_TOKEN into .env with mode 600. It starts Garage and waits for garage status. If the node has no role, it runs layout assign -z devbox -c 4T and layout apply --version 1. Then it runs bucket create yaos and key create yaos-worker, and saves the key ID and secret to .env. Last, it runs bucket allow --read --write --owner, and brings yaos up from a new build.
Garage as a private S3 store for one service, with nothing published to the host.
- One node
- Secrets from the environment
- Layout: one node, one zone, version 1
- One bucket, one key
- No published ports
- Runs as my uid
One More Caddy Block
The front door from part 3 handles this with no changes to how it works. *.dev.tailnet.example already resolves to the devbox's tailnet address, so a new name needs only a site block. TLS uses the same Cloudflare DNS challenge as the other sites. Caddy proxies WebSockets by default, so the wss:// sync socket needs no extra config.
I backed up the Caddyfile, ran caddy validate inside the container, then caddy reload. The certificate was issued within about 20 seconds, and the existing sites kept answering.
The name is only reachable on the tailnet, so only machines on the tailnet can open the claim page. A fresh yaos server is unclaimed, and the first person to open the page owns it. On a public URL that's a race against scanners. Here it isn't.
Claiming generates the token and an obsidian:// setup link with a QR code. Each device installs the YAOS plugin and opens the link.
One HTTPS front door with real certificates, for private service names that only lead somewhere from inside the tailnet.
- A wildcard record for *.dev pointing at 100.x.y.z
- A token scoped to one zone
- Host networking
- The token in a locked .env
- One block per service
Proving It With Their Tests
I didn't write the best evidence that this works. yaos ships a live test suite in tests/live that it normally runs against wrangler dev, and each test reads its host from YAOS_TEST_HOST. I claimed the server with a throwaway token and pointed the suite at workerd.2 The node toolchain ran in a node:22-slim container, since the devbox has no node installed.
All seven suites passed. The schema-guard suite checks that server and client agree on schema version 3. The provider-manual-connect suite checks that the y-partyserver provider connects. The sync-client suite, run twice, writes a new note, then appends to it on a second connect. The snapshots suite covers snapshot create, dedup, list and download, and a blob PUT, exists check and GET. The hardening-worker suite checks that oversized trace payloads are truncated, not fatal. The ws-ticket-reconnect suite covers short-lived tickets, reconnect, and reconnect after expiry. The ws-admission-protocol suite checks that schema and auth rejections arrive as fatal frames.
The snapshot and blob suite is the one that goes through the shim and Garage. Afterwards, garage bucket info yaos showed 4 objects. The Durable Object directory held one .sqlite file per object instance, plus workerd's metadata.sqlite.
Then I tested durability. live-seed-check in seed mode writes a note with a timestamp. I ran docker compose restart on both containers, then ran it again in verify mode. It read back the same timestamp.
Last, I ran sync-client and ws-ticket-reconnect against https://yaos.dev.tailnet.example, so they went through Caddy and over wss://. Both passed. The reconnect test ran slowly there, because it expects an 8-second ticket lifetime that the harness normally injects, and my server kept the default. It passed anyway.
When the tests were done, I wiped the data directories and ran setup.sh from empty. That checked the bootstrap from scratch and left an unclaimed server for me to claim for real.
Three machines now sync one vault of about 760 notes. When I type in a note on one, the others get the text as I type.
The First Day
The build held up on the first day of real use. Every problem came from the vault or from Obsidian, and none from workerd, Garage or Caddy. Here they are in the order they happened.
The rest were smaller. The upload counter stayed at "↑0/52" after the files were gone. The plugin drops uploads that fail for good from its queue but never counts them as done, and it only resets the counter when a new batch starts. Reloading Obsidian cleared it. On the second machine the files were gone but the empty folders stayed, because yaos syncs files, not folders. I deleted those by hand.
Deleted files stay in storage. The bucket kept about 201 MB of attachments that no longer existed in the vault, since the yaos server deletes old snapshots but never deletes attachments. I compared the attachment hashes the vault still references with the bucket listing and deleted the rest: 12,244 files, 201.1 MB. That left the 61 files, 49.8 MB, that the vault still uses. Garage gives the disk space back gradually, and its data folder went from 133 MB to 82 MB. Snapshots taken before the cleanup can still restore the notes, but not the deleted attachments.
One machine kept its vault in iCloud Drive, so Apple had a copy. After I moved it out, "4,393 files need attention" appeared and survived reloads. All 4,393 were from the stray project folder. My best guess at the order of events: the server had recorded them as deleted, but the moved copy had no synced record of them, so the plugin's safety check kept and flagged them instead of deleting them. The flags stayed after the files were gone. They're saved in .obsidian/plugins/yaos/data.json under _preservedUnresolved, so a reload doesn't clear them. I quit Obsidian, backed up that file, and removed only the flags for files that no longer existed on disk, with a short script.
Two messages look worse than they are. The settings page can say "Status: Disconnected" while sync works. The status bar, "YAOS: Connected", is the one to trust, and the settings line may be a display bug in the plugin. "Receipt: local state not yet received by server" is expected after a restart or reload. The server saves edits a few seconds after it gets them, and the confirmation catches up with the next edit.
The third machine went on with all of that in mind, and it synced cleanly.
Join an existing vault without creating a second one or running two syncs.
- Keep the vault outside iCloud Drive and other cloud-synced folders
- Turn off Obsidian's Sync core plugin
- Set up from the claim page's link or QR code, and check that the vault ID matches the other devices
- Set Exclude paths before the first sync, not after
- Watch the status bar for "YAOS: Connected"
- Type in a note and watch it appear on another device
What I Gave Up
Cloudflare was doing some work for me, and now the devbox does it.
Start with durability. Garage runs as one node with no replication, so my notes are as safe as the data disk and my backups. Everything lives under /srv/data/yaos, so that path needs adding to the restic job from part 5. For a consistent copy, I stop the stack first.
Then updates. The YAOS plugin has an "update server" button that rewrites a Cloudflare deploy repo, and it does nothing here. To update, I change YAOS_REF in the Dockerfile and rebuild. The plugin and server have to agree on the schema version, so I read the release notes first.
Attachment size is capped at 10 MB per upload. That's yaos's default, not something the shim adds. Empty (0-byte) attachments can't upload at all, because the server rejects them as "missing request body".
Deleted attachments stay. The yaos server never deletes attachments from storage, so a file deleted from the vault stays in Garage until I clean it up by hand, as on the first day.
And the shim has edges: no range reads, no conditional writes, no multipart uploads. Today yaos uses none of them.
At this point every device on the tailnet syncs notes and attachments in real time. With the last vault out of iCloud Drive, nothing leaves the house. The vault is files and SQLite on a disk I can see.
No comments yet