P2P Vault Sync
approvedby Shreekrishna chinta
Free, local-first P2P sync for your vault. No cloud, no account. - This plugin has not been manually reviewed by Obsidian staff.
Obsync
Free, local-first P2P sync for your Obsidian vault. No cloud. No account. No subscription.
Sync your Obsidian vault between your laptop and your phone over your own network — direct and private. Your notes never touch a third-party server.
Why Obsync?
Obsidian Sync costs $4/month and sends your vault through Obsidian's cloud. Obsync is an alternative that keeps your notes on your devices:
- No cloud, no accounts — devices talk directly over your LAN.
- Direct P2P transport — a dedicated sync protocol on your own network.
- Pairing by fingerprint — approve each device once; no passwords to share.
- Conflict detection — files edited on both sides are flagged, never silently clobbered.
- Local-first — your vault stays an ordinary folder on disk. No lock-in; leave anytime.
- Works on desktop and mobile — the same plugin runs on both, so a laptop pairs with a phone over a shared hotspot.
- Near-instant — vault changes propagate within a few hundred milliseconds (the mobile polls every 250 ms; both sides sync ~150 ms after an edit).
Requirements
- Obsidian desktop (Windows, macOS, or Linux) on the device that runs the server.
- Obsidian mobile on the device that syncs as the client.
- Both devices on the same network (Wi-Fi or phone hotspot), and the
client must be able to reach the server's IP on port
42042. - Enable the plugin on both devices. Server and client roles are a setting, not a separate app.
Install
- In Obsidian, open Settings → Community plugins, disable Restricted mode, and browse the catalog for P2P Vault Sync (this plugin).
- Install and enable it on both devices.
- Open Settings → Obsync — the laptop starts the server, the phone gets the server URL.
First-time walkthrough
- On the laptop: Settings → Obsync → Start server. The server listens on
port
42042and shows its fingerprint. - On the phone: Settings → Obsync → Set up connection. The phone finds the
server automatically — first on this device, then across your network
(localhost → LAN scan). If your server is on an unusual subnet, tap
Enter server URL manually and type the address shown under
Settings → Obsync → Connection on the laptop (e.g.
http://10.174.223.140:42042). - The phone appears under Awaiting approval on the laptop — click Approve.
- Tap Sync now again on the phone. Done — from here on it syncs automatically, and the phone reconnects on every launch.
Auto-discovery never guesses: a host is accepted only if it answers the Obsync ping with a valid protocol message, so an unrelated service on the same port is ignored. The URL is normalized for you:
10.174.223.140:42042works, nohttp://needed. Seesrc/core/transport.ts(normalizeServerUrl) andsrc/core/discovery.ts.
How it works
One device (usually your laptop) runs the server; the other device (usually your phone) runs the client. The laptop is authoritative for deletions — the phone is additive-only, so a partially synced phone can never delete files on the server.
Architecture
Laptop (authoritative) Phone (additive client)
┌──────────────────────────────────┐ ┌───────────────────────────────┐
│ Obsidian app │ │ Obsidian app │
│ └─ Obsync plugin │ │ └─ Obsync plugin │
│ ├─ RPC server :42042 │ │ └─ HTTP client │
│ └─ Sync engine │ │ └─ Sync engine │
│ └─ .obsync/index.json │ │ └─ .obsync/index.json│
│ └─ Vault folder │ │ └─ Vault folder │
└──────────────────────────────────┘ └───────────────────────────────┘
│ ▲ │ ▲
└────────────┴────────────────────┴────────────┘
HTTP /rpc (requestUrl)
The laptop runs an RPC server on port 42042; the phone (and the laptop's own
"Sync now") talk to it over HTTP POST /rpc — one protocol message per
request, the reply is the next message. Every message after the handshake
travels inside an encrypted envelope (see Security model).
Pairing a new device
Phone (client) Laptop (server)
│ 1. pair_request (device_id, fingerprint, name) │
│─────────────────────────────────────────────────────────►│
│ 2. pair_ack { approved: false } device recorded │
│◄─────────────────────────────────────────────────────────│ as pending
│ │
│ 3. Laptop user clicks "Approve" │
│ device → .obsync/approved.json │
│ │
│ 4. pair_request (same device) │
│─────────────────────────────────────────────────────────►│
│ 5. pair_ack { approved: true } │
│◄─────────────────────────────────────────────────────────│
│ 6. hello (device_id) │
│─────────────────────────────────────────────────────────►│
│ 7. hello_ack { approved: true } │
│◄─────────────────────────────────────────────────────────│
│ 8. full sync session follows │
One sync session
Client (phone or laptop) Server (laptop)
│ 1. hello │
│────────────────────────────────────────────────────────►│
│ 2. refresh index (authoritative)
│◄────────────────────────────────────────────────────────│ 3. hello_ack { approved }
│ 4. manifest (client file states) │
│────────────────────────────────────────────────────────►│
│◄────────────────────────────────────────────────────────│ 5. manifest (server file states)
│ 6. diff → pull / push / delete │
│ 7. file_request (path, offset) │
│────────────────────────────────────────────────────────►│
│◄────────────────────────────────────────────────────────│ 8. file_chunk (base64, is_last)
│ 9. sync_operation (create / update / delete) │
│────────────────────────────────────────────────────────►│
│◄────────────────────────────────────────────────────────│ 10. operation_ack
│ 11. disconnect │
Step 6 is where the client decides what to do with each file: pull files the server has, push files only the client has, and apply tombstones. Every step that touches a single file is isolated — a chronically-conflicting file cannot stall the rest of the session.
Conflict resolution
File differs on both sides
│
▼
Did either side change since the last agreed sync hash?
│
┌───────┴────────┐
▼ ▼
"No" "Both changed"
│ │
▼ ▼
newer mtime wins CONFLICT
(no agreement, → Settings → Obsync → Conflicts
pre-v2 fallback) Keep local / Keep remote / Keep both
Conflict model: revisions are per-device counters and are not a reliable "both changed" signal — the real signal is the
synced_hashcolumn (the content hash the last sync agreed on). A conflict is flagged only when both sides changed since that agreement. Seesrc/core/conflict.ts.
Security model
Anyone on the same network can address the sync port, so Obsync treats the network as hostile and derives trust from cryptography instead:
- Long-term identity keys. Every device generates an X25519 keypair at
first run (
.obsync/identity.json). The 8-byte SHA-256 prefix of the public key is the fingerprint shown during pairing — approvals are bound to this fingerprint, never to a self-claimed device id. - Sealed sessions. A client proves approval by completing a static-static
ECDH against the server's identity key:
hellocarries the client's public key and a fresh nonce, the server replies with its own nonce, and both sides derive the same AES-256-GCM key via HKDF-SHA256. Every following message is a sealed envelope{token, sequence, iv, ciphertext}with an ever-increasing sequence number (replay protection) and the token+sequence bound as AEAD associated data (tamper protection). - No unauthenticated surface. Only
ping(stateless discovery) is answered without a session; everything else getsauthentication required. An unapproved key never receives a session, so it cannot read even the manifest. - Pairing verification code. During first pairing both screens show the same 6-digit code derived from the ECDH secret. Compare them before clicking Approve — a match proves the peer holds the approved private key and rules out man-in-the-middle pairing on a hostile network.
- Known trade-off: traffic is encrypted but not anonymous — a network observer can still see that two devices talk to each other and how much data moves. Metadata (file names) is only visible to session holders.
Settings reference
Everything lives under Settings → Obsync:
| Section | What it does |
|---|---|
| Device identity | Device name + fingerprint shown to the server during pairing |
| Sync server | Start/stop the RPC server on the authoritative device (port 42042) |
| Setup (mobile) | One-tap entry to the onboarding flow that auto-discovers the server |
| Connection (mobile) | Live status ("Connected" / "Server not found"), plus Test connection and Change server |
| Server address (advanced) (mobile) | Manual server URL (normalized); leave empty to auto-discover again |
| Auto-sync | Sync on vault changes and (on mobile) poll the server every interval |
| Sync now | Force a session on demand (also available via the ribbon icon and the command palette) |
| Sync selection | Everything, or only files/folders you pick (see below) |
| Excluded files | Files kept out of sync in either mode |
| Devices | Approved devices, pending approval requests, revoke access |
| Conflicts | Files changed on both sides — resolve per file: keep local / keep remote / keep both |
| Versions | Snapshot history of every file; restore any past version |
Per-file sync selection
Two ways to control what syncs, under Settings → Obsync → Sync selection:
- Everything (default): the whole vault syncs. Right-click any file and choose Don't sync this file to keep it on that device only — excluded files are never pulled, pushed, or deleted, stay on disk untouched, and re-including resumes from the last agreed version.
- Only what I pick: nothing syncs until you mark it — right-click a file for Sync this file, or a folder for Sync this folder. A folder pick also covers files created under it later. "Don't sync this file" still works inside picked folders as an override.
Both modes share the same rules: selection is a pure filter over the index, so picking and unpicking never touches file content, and re-including a file resumes from the last version both sides agreed on without manufacturing conflicts. Folders can also be scoped per device from the desktop dashboard's Sync scope card; a file exclusion always wins over any folder rule.
How sync is triggered
- On edit — vault
create/modify/deleteevents trigger a debounced sync (~150 ms) on both platforms. - On poll — the phone polls the server every 250 ms (configurable, min 100 ms) to pick up remote changes; HTTP is request/response, so there is no push channel.
- On demand — the ribbon icon / "Sync now" command always forces a session.
Both are gated by a syncInProgress guard so sessions never overlap. See
main.ts.
Limitations
- One server, one vault at a time — the server device is authoritative; a second phone can pair, but there is no multi-master mode yet.
- Same-network only — no relay/TURN; both devices must reach each other directly.
- No filesystem watcher — Obsidian's edit events trigger sync, and the server re-scans the vault at the start of every session, so files edited directly on disk (outside Obsidian) are picked up on the next sync. The client's disk may be an incomplete replica, so it only ever adds, never tombstones.
- Alpha — works end-to-end; expect rough edges. The sync engine is covered by 96 tests, including cross-language conformance tests against the Rust core.
Development
npm install
npm run build # tsc typecheck + esbuild bundle → main.js
npm test # vitest (96 tests: engine, sync, store, scanner, crypto, pairing…)
The engine is a TypeScript port of the Rust sync engine in chintu79/obsync, with cross-language conformance tests keeping the two implementations equivalent.
Project layout
| Path | What it is |
|---|---|
main.ts | Plugin entry: server/client bootstrap, auto-sync, status bar |
src/core/engine.ts | Port of the Rust sync engine (index, manifest, conflict planning) |
src/core/session.ts | The sync session protocol (hello/manifest/pull/push) |
src/core/pairing.ts | Device approval + pending-request tracking |
src/core/transport.ts | HTTP framing + server + URL normalization |
src/core/store.ts | JSON state store (.obsync/index.json) |
src/core/conflict.ts | Divergence detection (the synced_hash rule) |
src/ui/settings-tab.ts | Settings UI: server, URL, auto-sync, devices, conflicts, versions |
docs/device-test.md | End-to-end checklist for testing on real hardware |
Release
Releases are cut by maintainers. Tag a version with the exact
manifest.json version, no v prefix:
git tag 1.0.4
git push origin 1.0.4
The release workflow builds main.js and
attaches main.js + manifest.json + styles.css to a GitHub release.
Contributing
We welcome contributions of all kinds — code, docs, bug reports, feature ideas. See CONTRIBUTING.md to get started.
Related
- Obsync core (Rust) — the sync engine,
httpddashboard, and Android app this plugin's engine is ported from. - Device test checklist — verify pairing, both-way sync, conflicts, and restart persistence on real hardware.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in Obsync shall be dual-licensed as above, without any additional terms or conditions.
© 2026 Obsync contributors.
For plugin developers
Search results and similarity scores are powered by semantic analysis of your plugin's README. If your plugin isn't appearing for searches you'd expect, try updating your README to clearly describe your plugin's purpose, features, and use cases.