pc418162 downloadsSync your vault to your own Cloudflare account — end-to-end encrypted, versioned snapshots, with an optional MCP server that lets Claude or any AI assistant read and write your notes.
Sync your vault to your own Cloudflare account — end-to-end encrypted, versioned snapshots, with an optional MCP server that lets Claude or any AI assistant read and write your notes.
No third-party service and no subscription: a Worker, an R2 bucket and a Durable Object you deploy in minutes, and the free plan is enough for a text vault. The master key never leaves your devices, so the server holds ciphertext and an opaque path map — never a filename.
The MCP half is off unless you ask for it, and is a separate Worker with its own credential — see Connect an AI assistant, including what it costs you in privacy.
Not affiliated with Obsidian, and not the official sync service.
A Cloudflare account (free plan), Node.js 20+, Obsidian, 5 minutes.
git clone https://github.com/pc418/cloudflare-r2do-sync.git && cd cloudflare-r2do-sync
npm --prefix worker install && npm --prefix plugin install
node scripts/setup.mjs
Log in first, if you have not already, with the wrangler this repo pins —
node worker/node_modules/wrangler/bin/wrangler.js login (same on every OS; setup uses only
that copy and never logs you in or out). Setup prints which account it is about to
deploy to and waits for you to confirm, then creates everything it needs and issues your
access token. Every step is idempotent — re-running after a failure resumes. The admin
credential lives in gitignored .env, managed for you.
════════════════════════════════════════════════════════════════════
PASTE THESE INTO OBSIDIAN
════════════════════════════════════════════════════════════════════
Server URL https://obsidian-log-sync.<your-subdomain>.workers.dev
Access token <64 hex characters>
...
Then install the plugin — R2DO Sync is in Settings → Community plugins, or build it yourself. The Worker is yours to deploy either way:
cd plugin && node build.mjs && cd ..
node scripts/install-plugin.mjs "/path/to/Your Vault"
The random key is recommended. Set from passphrase derives the same key on any device, but a weak passphrase is open to offline guessing.
On the configured device, open Set up another device: Show QR for a phone (the code
is an obsidian:// link the camera opens directly) or Show setup link for anything
else. The link is shown as selectable text — copy it yourself and paste it into the new
device with Apply a setup link. Nothing is typed by hand, and the plugin never reads or
writes your clipboard, so it asks for no clipboard permission.
Do this before the new device's first sync. URL and token typed in manually cannot join an encrypted vault — neither carries the master key — so the device stops and offers the paste box instead. That halt is the safety net working.
A new device that already holds a copy of the vault is fine: identical files match by content, the rest merges, and anything ambiguous keeps both copies. Nothing local is deleted on a first sync.
Install from Community plugins, then set up by QR from a configured device. If you would
rather not wait on the store, let a computer do it once: set the vault up on a desktop, zip the
whole vault folder, and unzip it inside Obsidian's folder on the phone — the hidden .obsidian
folder travels inside the zip even on iOS, carrying plugin, settings and keys. Change Device
name afterwards.
Once running, the ribbon icon is the sync button. By default a pass that moved files says so and a pass that found nothing stays quiet; What sync announces turns that up to every pass or down to nothing. If you want a tap acknowledged even when there was nothing to do, that is Say when a sync starts. Returning to the app counts as startup — if the last pass is older than Sync on returning to the app (15 minutes by default, 0 to never), a sync runs. Returning fires more often than it sounds — a screen unlock counts — so raise that number, or set it to 0, if the phone syncs more than you want.
To sync without any pop-ups at all, set What sync announces to Silent — but turn on Show the status bar on mobile first. Obsidian hides the status bar on phones, so without it a silent device has nothing on screen to say a sync has started failing.
An optional second Worker lets Claude, Codex or any MCP client read — and, if you allow it, write — this vault, with no Obsidian running anywhere. Ask what you wrote about something last month, file a note from your phone, keep a log. Off by default and entirely skippable: nothing in the plugin depends on it.
Understand the trade first. This Worker holds your vault master key, the one secret the sync Worker never sees. It decrypts your notes to answer, so whatever it reads reaches your model provider as plaintext. "The server cannot read your notes" does not extend to this one — that is the point of it being separate and opt-in.
# Read-only: answers questions, changes nothing.
node scripts/deploy.mjs --vault my-vault --agent
# Read and write: adds capture and editing.
node scripts/deploy.mjs --vault my-vault --agent --agent-writable
--agent needs --vault and refuses your default deployment, so your main vault's key cannot
reach a Worker by way of a flag on a routine deploy. For that one, deploy-agent.mjs --live is
the only path, and it requires --deny (below) — naming what stays out of reach is meant to
be a decision, not a step you skip.
Everything a client needs lands in one file at the repository root:
DEPLOY-CREDENTIALS.my-vault.txt (mode 0600, gitignored)
The URL and the token, ready to paste. The terminal shows only the URL and a short fingerprint — enough to tell one deploy from another, useless for signing in — so the credential never sits in your scroll-back. Store the values somewhere safe, then delete the file.
The URL is the one ending in /mcp, never /sse. There is one token, and two ways to
present it. Use whichever your client offers; they end at the same place.
Sign in. Add the URL and pick OAuth. A page titled Authorize this connector opens in your browser and asks for one thing — the token, pasted bare. There is no account and no password: that token is the sign-in. Nothing is stored server-side, so there is nobody to lock out but the holder of the token.
Send a header. Authorization: Bearer <token>, verbatim, Bearer and the space included.
Scriptable clients prefer this — no browser, nothing to click.
| client | how |
|---|---|
| Claude (web, desktop) | Settings → Connectors → Add custom connector, paste the URL, sign in when prompted. A Request headers section, if your account has that beta, works too. |
| ChatGPT | Settings → Connectors → add the URL and sign in. Speaking OAuth is what that surface requires, so this is the ordinary connector path — developer mode is not needed. Web only; write tools may be restricted by plan. |
| Claude Code | claude mcp add --transport http obsidian <url>/mcp --header "Authorization: Bearer <token>". Use -s user; avoid -s project, which writes the credential into a committed .mcp.json. Servers load at startup, so restart the session. |
| Codex | codex mcp add obsidian --url <url>/mcp --bearer-token-env-var OBSIDIAN_MCP_TOKEN, then export that variable from your shell profile rather than a config file. No Bearer prefix there; Codex adds it. |
Register the workers.dev URL exactly. A redirect to another host drops the header, and
sign-in discovery starts from whatever host you typed. Connector auth cannot be edited after
creation, which is why a redeploy never reissues the token; if you run --rotate-bearer, remove
and re-add the connector.
Read-only gives search, read, list and recent. Writable adds append, edit, write,
delete and move on a second, separately revocable credential.
The write tools behave like a file system: write replaces a note outright, delete removes
one, neither asks for confirmation, and folders come and go with the notes in them. Your undo
is snapshot history, so the retention window is what a mistake costs, and recovery is done from
the plugin — the assistant cannot do it for you. move is the exception to the plain-rm
reading: it refuses to land on a path that already exists.
Dates it reports are your vault's, not the server's — the deploy records your machine's timezone, so "yesterday" means what you mean.
Your vault's own exclude and only-paths rules bind it on reads as well as writes, and everything it writes syncs to your devices like any other device's changes, mass-deletion guard included.
The connector is sized for one note at a time: appending to a log, saving a conversation, revising or replacing a single note, looking something up. Every call is a round trip that absorbs the vault head, applies one change and commits — which is what makes it safe from a phone with no Obsidian running, and what makes it the wrong shape for bulk work.
For anything that touches many files at once — a rename sweep, a bulk reformat, a find-and-replace across a folder, a restructure — point Claude Code or Codex at the vault folder on disk instead. They already have the filesystem, so it is one pass over local files with a normal diff to review, and the plugin syncs the result as one snapshot. Driving the same job through the connector means one commit per file, a slower run, and a mass-change guard that will stop your other devices to ask about it. Use both: the connector when you are away from the vault, the local tools when you are in it.
node scripts/deploy-agent.mjs --vault my-vault --deny "Private/**, Keys/**"
Anything matching is invisible to every tool — it cannot be listed, searched, read, written or
deleted — and the rule lives where the Worker is deployed, so nothing you sync can widen it.
That is deliberately different from the vault's own excludes, which are a synced setting any
device can edit and which also stop the folder reaching your other devices. Use --deny for a
folder you want on all your devices but not in front of a model.
Two honest limits. It governs what the agent may decrypt, not what your vault has stored: those
notes stay in snapshots already taken, exactly as excluded ones do, and only a re-root removes
them. And a glob that matches nothing denies nothing — check the deploy's deny list: N glob(s)
line, then try to read something under the folder and confirm it refuses.
Put a note called AGENTS.md at your vault root:
- Daily notes live in `Daily/YYYY-MM-DD.md`.
- Read `Inbox.md` first for what I am working on.
- When I say *log X*, append X under `## Log` in today's daily note.
It is served when a conversation starts, so you can ask for things in fewer words. An ordinary
note: edit it anywhere, history versions it, and a writable deployment can edit it itself — with
new text taking effect in your next conversation, not the current one. It is advice to the
model, never configuration: it cannot un-hide a path or change what a tool does, and an
AGENTS.md your vault excludes is invisible.
Some clients drop server instructions entirely. Every tool description points at AGENTS.md to
cover that. If the assistant still seems not to know your conventions, ask it what its
instructions for this vault say — an empty answer means that client dropped them.
Redeploying is safe and boring: code and settings are replaced, and nothing is reissued unless you ask, so a working client keeps working.
| To change | Pass | What it costs |
|---|---|---|
| the connector token (sign-in and header alike) | --rotate-bearer |
connector auth cannot be edited after creation, so remove and re-add the connector |
| the agent's own vault tokens | --rotate-tokens |
nothing — no client sees these; the pair it replaces is revoked once the new one answers |
| what it may not touch | --deny "…" |
immediate; --deny "" clears it |
| read-only ⇄ read-write | add or drop --writable |
dropping it deletes the write credential outright, not just the tool list |
A failed deploy revokes anything it created before stopping; a successful one retires what it replaced only after the new deployment answers. So you are never left with a live credential nothing names, nor a working agent whose credential was withdrawn under it. While the handover file still exists a redeploy keeps the admin token it names — otherwise the file would quietly stop being true — and once you delete it the next deploy issues a fresh one.
To remove it: delete the agent Worker in the Cloudflare dashboard and revoke its tokens with
node scripts/access-token.mjs --vault my-vault --list then --revoke <id>. Your vault and
devices are unaffected. If you want its search index gone too, drop it first with an
authenticated DELETE <agent-url>/admin/index — deleting a Worker's code is not the same
operation as deleting its stored data.
note.conflict-<device>-<yymmdd-HHmm>.md; nothing is overwritten in place. A window shows
the line-by-line difference with four choices: keep this device's, keep the other's, keep
both files, or combine into one with the disagreements marked for you to edit.
Background syncs park the copy and wait; Review and resolve conflicts reopens the
latest batch any time.⇧⌘S offered when free)..conflict-… copies; Push local over remote publishes
without merging. Both preview what they will touch and require a typed confirmation, and
the replaced snapshot stays in history.SYNC CONFIG
confirmation, and the credential folders and workspace*.json stay excluded even then.REKEY operation; ordinary sync stops
until it is resolved.| Setting | Default | Trade |
|---|---|---|
| Parallel lanes | 4 | Files processed at once; higher is faster but heavier on a phone or slow link |
| Sync log length | 50 | Passes kept for troubleshooting |
| Rows listed in history | 40 | Each one is a request. A row is one sync, one day or one week, whichever the history window is grouped by |
| Automatic retries | 3 | Backoff after a failed pass; a halted sync is never retried — it needs a person |
| What sync announces | Activity | One ordered choice: All (every pass), Activity (only passes that changed something), Problems (conflicts and errors), Silent (nothing). State still shows in the status bar and the sync log at every level — only the pop-ups stop |
| Say when a sync starts | off | The "syncing…" pop-up while a sync you started runs. Separate from the level above because it answers your tap, so it works at every level |
| Label | Cloudflare R2DO Sync |
The name in front of every notice, with its own on/off |
| List the changed files | off | Names each file that moved instead of counts alone, and is what puts the snapshot id in the notice |
| Show the status bar on mobile | off | Forces Obsidian's hidden mobile status bar open, so sync state is readable without notices |
| Sync settings between devices | on | Shares vault-wide settings through the server, encrypted like notes; most recent change wins |
| Snapshot retention (server) | 14 days, then thinned | Not a plugin setting — see Limits |
Vault-wide settings (excludes, thresholds, intervals, direction…) sync between devices. Credentials, Device name, Parallel lanes, everything under Notices and config-folder consent deliberately stay per-device — "quiet on my phone, everything on my desktop" is the ordinary case, and a shared switch cannot express it.
Everything below is how it works rather than how to use it.
Most sync plugins compare timestamps and sizes and guess: newer wins, one side is silently discarded, deletions are inferred rather than observed, and two devices uploading at once interleave. R2DO Sync keeps a commit log instead:
The Worker is the API, R2 stores the encrypted blobs and manifests, and the Durable Object serializes commits. All of it runs in your account; the server sees only ciphertext and an opaque path map — never a filename or a key.
Continuity check. Before merging a snapshot it has not seen, a device traces it back to the one it last synced — usually the snapshot's own parent, costing nothing. If the trail runs out (history rebuilt, or the device away longer than the server keeps history), the pass stops and asks instead of merging a history it cannot place. On an encrypted vault every link the check follows is authenticated with the vault key, so the trail cannot be forged by whoever serves it.
Snapshot ids are shown as their last 7 characters everywhere on screen. They are ULIDs, so the first ten characters are the timestamp and the end is the part that identifies the snapshot. The exported Sync log keeps all 26, because that is the id the server API takes.
sha256(ciphertext), so it can verify integrity
without reading anything..env, not on the server, not
recoverable by anyone including you. Keep a copy somewhere safe.workspace*.json are never synced — data.json
holds this device's token and master key in plaintext.plugins/<id>/data.json is where your other
plugins keep credentials. Obsidian's own settings files still sync.data.json in plaintext; "encrypted" means the server cannot read your notes, not a
sandbox on your own machine.scripts/restore.mjs
decrypts a snapshot without the plugin.Two credentials, deliberately separate — there are no device accounts:
| what it does | where it lives | |
|---|---|---|
| Access token | read/write the vault | every device, in plugin settings and QR codes |
| Admin token | issue and revoke access tokens; cannot read the vault | .env, managed by the scripts |
One access token shared by all devices is the normal setup. The split makes recovery cheap:
a lost device or leaked token is one revocation, no redeploy. If .env is lost, re-running
setup rotates the admin credential without touching access tokens.
node scripts/access-token.mjs # issue it — replaces the existing one
node scripts/access-token.mjs --list # active tokens (no token material)
node scripts/access-token.mjs --rotate # fresh token, revokes ALL others
node scripts/access-token.mjs --name phone # an extra token, revocable on its own
node scripts/access-token.mjs --revoke <id>
node scripts/access-token.mjs --out token.json # 0600 file instead of the screen
A token prints only to a terminal; piped into a file or CI log it refuses (use --out or
--print-token). Replacing a token kills the old one immediately — devices holding it stop
syncing until they get the new one.
A token can also carry less than full authority — without the reroot scope (rebuilding
remote history is the only action that makes remote content stop existing) or with an expiry:
curl -X POST "$WORKER_URL/api/tokens" -H "authorization: Bearer $ADMIN_TOKEN" \
-H 'content-type: application/json' \
-d '{"name":"phone","scopes":["sync"],"expiresAt":"2027-01-01T00:00:00Z"}'
scopes: ["read"] is weaker still: it reads the head, the snapshot chain, manifests, blobs
and shared settings, and is refused — 403, without touching the vault — on every route that
changes anything, uploads included. It is what a read-only agent deployment is given.
Ordinary devices need sync, which covers reading too, so existing tokens are unaffected.
The plugin is R2DO Sync, but the Cloudflare resources keep this project's original name,
obsidian-log-sync. That is what to look for in the dashboard:
| Cloudflare | Name | Where |
|---|---|---|
| Worker | obsidian-log-sync |
Workers & Pages — serves obsidian-log-sync.<your-subdomain>.workers.dev |
| R2 bucket | obsidian-log-sync |
R2 — every snapshot and blob your vault has |
| Durable Object | VaultLock |
the Worker's bindings — holds the authoritative head |
All three come from worker/wrangler.jsonc. Renaming them is a migration, not an edit — a
new Worker name means a new, empty Durable Object, and R2 buckets cannot be renamed at all —
so scripts/deploy.mjs refuses to deploy a rename it would fork. The same file's vars hold
snapshot retention (GC_KEEP_DAYS / GC_KEEP_COUNT / GC_DAILY_DAYS); every deploy restates
them, so editing
the file and redeploying is how they change.
One deployment serves exactly one vault (one Durable Object head), so a second vault is a second deployment, not a second bucket behind the same Worker:
node scripts/setup.mjs --token --vault notes-2 # or VAULT_NAME=notes-2
The name becomes the Worker script and R2 bucket name, and the Durable Object namespace
follows the script. Nothing is shared with your first vault except the account. The new
vault's URL and admin credential are written to gitignored .env.<name>, never .env;
pass the same --vault to every later command for that vault:
node scripts/deploy.mjs --vault notes-2 # redeploy after a Worker change
node scripts/access-token.mjs --vault notes-2 # re-issue the access token
node scripts/access-token.mjs --vault notes-2 --list
Pass --vault to every later command for that vault. Without it a script means your
default deployment and succeeds there rather than failing usefully; the resolved vault is
printed before anything is created. A name is refused if it is your default deployment's name,
an unrelated existing bucket, anything containing sandbox (the marker
scripts/sandbox.mjs --destroy --all deletes), or a Worker already on your account —
--adopt-worker is the deliberate way past, and only right for a vault of your own whose
.env.<name> you lost.
Use it with a different Obsidian vault; pointing an existing device at a new deployment
copies nothing, it just changes which empty remote it syncs with. Nothing changes in the plugin
— each Obsidian vault keeps its own settings, so you set the second up exactly like the first,
and the vault name never reaches the plugin, which only ever sees a URL. REST path only, because
wrangler deploys whatever worker/wrangler.jsonc names.
For CI, or a machine whose wrangler is signed in to a different account: copy .env.example
to .env, fill in CLOUDFLARE_TOKEN (scopes: Workers Scripts:Edit, Workers R2
Storage:Edit) and CLOUDFLARE_ACCOUNT_ID, then node scripts/setup.mjs --token. With both
present this path is chosen automatically; the script never silently switches accounts.
scripts/restore.mjs decrypts any snapshot to a plain directory — no Obsidian, no plugin:
node scripts/restore.mjs --out ./restored # current head
node scripts/restore.mjs --out ./restored --head <manifest-id>
node scripts/restore.mjs --out ./restored --passphrase --salt <public-vault-salt>
It prompts for the access token and master key (or reads ACCESS_TOKEN / MASTER_KEY), so
neither lands in shell history. It re-implements the crypto independently on purpose, and a
test keeps the two byte-compatible — a bug in the plugin cannot make your backups unreadable.
GC_KEEP_DAYS (14) or the newest GC_KEEP_COUNT (100), whichever
reaches further back; then one a day out to GC_DAILY_DAYS (90); then one a week, kept
indefinitely. So recent work is restorable to the individual sync, last month to the day, and
older history never expires — set in worker/wrangler.jsonc, restated on every deploy.
Retained snapshots restate the whole path map, so this — not file content — is usually what a
vault's storage is spent on. The dense window is also how long a device can be offline and
still merge cleanly against a shared base; shortening it makes that window smaller.# All commands are run from the repository root.
npm --prefix worker install && npm --prefix plugin install
npm --prefix worker test # real workerd via vitest-pool-workers
npm --prefix plugin test # incl. rendered settings-tab/modal coverage
npm --prefix agent test # the optional MCP Worker
node --test scripts/*.test.mjs # deploy/setup/release/token helpers
npm --prefix plugin run lint # typed lint; the baseline is zero, so any finding is new
npm --prefix worker run lint
npm --prefix agent run lint
# Optional: the same plugin driven against a REAL deployed Worker, with real files on disk.
# Needs a throwaway sandbox on a Cloudflare account that is not the one holding your vault.
node scripts/sandbox.mjs # deploy one; --suffix <group> for an isolated second
npm --prefix plugin run test:live # skips entirely when no sandbox is deployed
node scripts/sandbox.mjs --destroy --all
npm --prefix plugin run build # -> plugin/dist/{main.js,manifest.json,styles.css}
node scripts/release-validate.mjs 1.0.0 # release layout check; must run from the root
worker/wrangler.jsonc is the single source of deployment metadata for both deploy paths.
A release is cut by pushing a tag equal to the manifest.json version:
.github/workflows/release.yml runs the suites, builds, attests the assets and publishes
main.js, manifest.json and styles.css on the GitHub release.
GNU Affero General Public License v3.0 or later — see LICENSE. Free to use, modify and share; if you run a modified version as a network service, its users are entitled to that version's source. Since everything here runs on your own Cloudflare account rather than as a service you offer to others, ordinary personal use carries no such obligation.
Your privacy, now verifiable. How much is it worth to you?