andrewkopylev56 downloadsBridge your vault across devices through your own SSH/SFTP server. Bidirectional sync with conflict resolution, multi-device safety, and full self-hosting.
Sync your Obsidian vault across desktops through your own SSH/SFTP server. No cloud, no proxy services, no subscriptions — your notes go straight between your machines and your server.
A bidirectional sync engine for Obsidian vaults that uses SFTP/SSH as transport. It does proper 3-way diffing (so it can tell "you edited" from "they deleted"), preserves both versions on conflict, and protects you from catastrophic operations.
notes/foo (conflict from device-A 2026-04-28 14-30).md.rm, server restored from backup), the plugin refuses to interpret that as "delete everything locally" and offers safe recovery options.Good fit if:
Not a good fit if:
git clone https://github.com/andrewkopylev/vaultbridge.git
cd vaultbridge
npm install
npm run build
./install.sh /path/to/your/vault
Then in Obsidian → Settings → Community plugins → enable Vault Bridge SFTP.
main.js and manifest.json from the latest release.<vault>/.obsidian/plugins/vault-bridge-sftp/ and place both files inside.Coming soon — see RELEASING.md for submission status.
/home/me/obsidian-vault. Created if it doesn't exist..sync/ directory inside it.Ctrl+P → Vault Bridge: Sync now). The first sync uploads your full vault — expect this to take a while; later syncs only transfer what actually changed.| Setting | Description |
|---|---|
| Host / Port / Username | SSH connection info. |
| Authentication | Password or Private key (with optional passphrase). Secrets stored encrypted (see Security notes). Host key pinned on first connect (TOFU). |
| Remote root | Absolute path on the server. Created on first connect if missing. |
Sync everything (.obsidian too) |
When ON, plugins/themes/snippets/hotkeys are synced so all devices look identical. The plugin's own state/ directory is always excluded regardless. Default: ON. |
| Sync workspace.json | When OFF, panel-layout files stay device-specific (recommended — turning it ON causes flapping when working on two devices at once). Default: OFF. |
| Exclude patterns | Gitignore-style. One per line. |
| Sync on startup | Run a full bidirectional sync after Obsidian loads. Default: ON. |
| Sync on quit | Best-effort push when Obsidian closes (5-second timeout, push-only — no prompts). Default: ON. |
| Sync after changes | Debounced sync triggered by vault edits. Default: ON. |
| Debounce delay | Seconds to wait after the last edit before auto-syncing. Default: 10. |
| Concurrent transfers | How many uploads / downloads run in parallel inside a single SFTP connection. Range 1-20, default 8. Higher hides RTT on slow links; lower is gentler on small servers. |
| Device label | Human-readable name used in conflict-copy filenames. Per-device, not synced. |
All commands are accessible via Command Palette (Ctrl+P / Cmd+P):
| Command | What it does |
|---|---|
Vault Bridge: Sync now |
Bidirectional sync. Pulls changes, pushes yours, handles conflicts. The everyday command. Also bound to the ribbon icon and the status bar click. |
Vault Bridge: Test connection |
Verify SSH credentials and create remote root if missing. |
| Command | What it does |
|---|---|
Vault Bridge: Pull from server |
Download additions and updates only. Never modifies the server. Useful for refreshing a fresh device. |
Vault Bridge: Force push everything |
Re-upload every local file regardless of remote state. Rewrites manifest. Use after manifest corruption. |
Vault Bridge: Force pull everything |
Re-download every file from the manifest, even if local sha1 matches. Use after local index corruption. |
| Command | What it does |
|---|---|
Vault Bridge: Inspect remote state |
Show server-side manifest generation, file count, last writer, lock status. |
Vault Bridge: Force-release remote sync lock |
Release a stuck lock that belongs to this device (foreign locks are not touched). |
Vault Bridge: Forget remembered host fingerprint |
Drop the pinned SHA-256 host-key fingerprint for the current host:port. Next connect re-trusts on first contact. Use only after a deliberate server reinstall. |
Vault Bridge: Rebuild remote manifest |
Walk the actual server filesystem, hash every file, rewrite the manifest. Use after manual file changes on the server. |
Vault Bridge: Reset local snapshot |
Wipe this device's "last sync" record. Next sync treats every local file as a fresh addition. |
Vault Bridge: Rebuild local index |
Force a re-scan and re-hash of every local file. |
Vault Bridge: Show index stats |
Quick stats: file count, total size, last full scan timestamp. |
The engine compares three sources for every path on every sync:
<remoteRoot>/.sync/manifest.json on the server)Decision matrix per path:
| L vs S | R vs S | Action |
|---|---|---|
| unchanged | unchanged | skip |
| changed / added | unchanged | push |
| unchanged | changed / added | pull |
| changed (same content as R) | changed (same content as L) | record, no I/O |
| changed | changed (different) | conflict-copy + winner by mtime |
| deleted | unchanged | delete on server |
| unchanged | deleted | delete locally |
| deleted | changed | restore from remote |
| changed | deleted | restore from local (push back) |
| deleted | deleted | drop from snapshot |
In <remoteRoot>/.sync/:
manifest.json — {generation, entries: {path: {mtime, size, sha1}}}. Each successful sync bumps generation.lock.json — held during a sync. Stale locks (>5 min) are taken automatically.In <vault>/.obsidian/plugins/vault-bridge-sftp/state/ (never synced):
index.json — current local indexlast-synced.json — snapshot Sdevice.json — per-device id and labelsecret.key — 256-bit AES key used to encrypt password/passphrase in data.json. Generated on first run.known-hosts.json — pinned SHA-256 host-key fingerprints (TOFU)If both devices edit notes/foo.md before either has synced, the second to sync gets:
notes/foo.md — winner (newer mtime)notes/foo (conflict from device-XYZ 2026-04-28 14-30).md — loser, preserved next to the originalYou decide what to do (merge, keep one, etc.) in your editor.
The manifest still has the entry, so the next sync sees nothing changed. To propagate the deletion:
When the server manifest is empty (gen=0) but your local snapshot has gen > 0, the plugin detects this and shows the Server Reset dialog with three options:
This blocks the catastrophic "treat empty manifest as N deletions" path before the bulk-delete modal even runs.
If a device crashed mid-sync, its lock will go stale after 5 minutes and the next sync will take it. To break it sooner, run Force-release remote sync lock on the device that holds it (foreign locks are intentionally untouched).
Run Rebuild local index — full re-scan and re-hash. Cheap on small vaults.
Default soft excludes:
.trash/** (Obsidian's local trash).obsidian/workspace.json, workspace-mobile.json — only when "Sync workspace.json" is OFF (recommended)Hardcoded excludes (cannot be turned off):
.obsidian/plugins/vault-bridge-sftp/state/** — the plugin's own state. Recursive sync would corrupt the index.You can add gitignore-style patterns in Exclude patterns in settings:
node_modules/**
*.tmp
private/secrets.md
<vault>/.obsidian/plugins/vault-bridge-sftp/state/secret.key. The state directory is hard-excluded from sync, so even if data.json (with the encrypted blob) is pushed to the SFTP server alongside the rest of .obsidian, the key needed to decrypt it stays on the originating device. This is defense-in-depth, not protection against malware running locally with your privileges. SSH key authentication is still preferred on shared machines.state/known-hosts.json. Subsequent connections refuse to proceed if the fingerprint changes — protecting against silent man-in-the-middle. After a deliberate server reinstall run Forget remembered host fingerprint to re-trust on next connect..sync/ directory is world-readable by default. Lock down permissions if you store the vault on a multi-user server.isDesktopOnly: true). Mobile Obsidian cannot open raw SSH sockets.git clone https://github.com/andrewkopylev/vaultbridge.git
cd vaultbridge
npm install
npm run dev # esbuild watch mode
npm run build # production build → main.js
./install.sh <vault> # copy main.js + manifest.json into <vault>/.obsidian/plugins/vault-bridge-sftp/
Source layout:
src/
├── main.ts # plugin entry, command wiring, vault events, triggers
├── settings.ts # settings schema + UI tab
├── sftp/
│ ├── client.ts # ssh2-sftp-client wrapper
│ ├── remote-state.ts # manifest + lock management on the server
│ └── transfer.ts # atomic upload/download primitives
├── sync/
│ ├── diff.ts # 3-way diff — pure function
│ ├── sync-engine.ts # bidirectional orchestrator
│ ├── push-engine.ts # one-way push (force-push)
│ ├── pull-engine.ts # one-way pull (additive)
│ ├── manifest-rebuilder.ts # walk server, hash, rewrite manifest
│ ├── concurrency.ts # bounded parallel pool (runWithLimit)
│ ├── exclude.ts # gitignore-style matcher
│ ├── hash.ts # sha1
│ ├── index-store.ts # local file index
│ ├── last-synced.ts # snapshot S
│ └── scanner.ts # walk vault, build index
├── state/
│ ├── paths.ts # state-dir path constants
│ ├── device-store.ts # per-device id/label
│ ├── secret-store.ts # AES-256-GCM encryption of password/passphrase
│ └── known-hosts-store.ts # TOFU host fingerprint pinning
└── ui/
├── bulk-delete-modal.ts # 5%/20-file deletion confirmation
└── server-reset-modal.ts # gen=0 vs S>0 recovery dialog
MIT — see LICENSE.