README file from
GithubVault Bridge SFTP
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.
What it does
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.
Features
- Bidirectional sync with 3-way diff (local + remote manifest + last-synced snapshot). Pulls others' changes, pushes yours, in one operation.
- Conflict-copy resolution — when the same file was edited on two devices, both versions are kept. The newer mtime wins on the original path; the loser becomes
notes/foo (conflict from device-A 2026-04-28 14-30).md. - Multi-device safe — server-side lock prevents two devices syncing simultaneously; manifest generation counter detects out-of-order writes.
- Bandwidth-efficient — SHA-1 per file decides what actually changed; identical files are skipped.
- Bulk-delete protection — if a sync would delete more than 5% of files (or 20+), a modal lists them and offers Continue / Skip-deletes / Cancel.
- Server-reset detection — if the remote manifest is wiped (manual
rm, server restored from backup), the plugin refuses to interpret that as "delete everything locally" and offers safe recovery options. - Self-heal — if a file in the manifest is missing from the server's filesystem, the plugin drops the entry instead of crashing.
- Auto-sync triggers (all toggleable): on Obsidian start, on quit (push-only, best-effort), and after vault changes (debounced, default 10s).
- Atomic transfers — every upload and download goes through a temp file + rename, so an interrupted sync never corrupts a target.
- Password or SSH key authentication, with passphrase support.
When to use this
Good fit if:
- You have your own server, VPS, NAS, or home machine reachable via SSH.
- You want multi-device sync without paying for Obsidian Sync.
- You don't want Dropbox / Google Drive / iCloud touching your notes.
- You want a transparent sync — open-source, plain JSON manifest, plain SHA-1 hashes; nothing proprietary.
Not a good fit if:
- You need mobile sync. Mobile Obsidian (iOS/Android) cannot open raw SSH sockets — this plugin is desktop only. Use Obsidian Sync or an OS-level sync (Syncthing) for mobile.
- You only have one device. Just back up your vault folder.
- You don't have an SSH server.
Installation
From source
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.
Manual install (after a release is published)
- Download
main.jsandmanifest.jsonfrom the latest release. - Create
<vault>/.obsidian/plugins/vault-bridge-sftp/and place both files inside. - In Obsidian: Settings → Community plugins → enable Vault Bridge SFTP.
From the Obsidian Community Plugin store
Coming soon — see RELEASING.md for submission status.
Quick start
- Open Settings → Vault Bridge SFTP.
- Fill in:
- Host / Port / Username — your SSH server.
- Authentication — Password OR Private key (with optional passphrase).
- Remote root — absolute path on the server, e.g.
/home/me/obsidian-vault. Created if it doesn't exist.
- Click Test connection. The plugin will create the remote root and an empty
.sync/directory inside it. - Click Sync now (or
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.
Settings reference
| 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. |
Commands reference
All commands are accessible via Command Palette (Ctrl+P / Cmd+P):
Daily use
| 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. |
One-way operations
| 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. |
Maintenance
| 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. |
How sync works
The engine compares three sources for every path on every sync:
- L — local index (current vault state with SHA-1 hashes)
- R — remote manifest (
<remoteRoot>/.sync/manifest.jsonon the server) - S — last-synced snapshot (the manifest from the last successful sync, kept locally)
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 |
Server-side metadata
In <remoteRoot>/.sync/:
manifest.json—{generation, entries: {path: {mtime, size, sha1}}}. Each successful sync bumpsgeneration.lock.json— held during a sync. Stale locks (>5 min) are taken automatically.
Local per-device metadata
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 indata.json. Generated on first run.known-hosts.json— pinned SHA-256 host-key fingerprints (TOFU)
Multi-device guide
Adding a second device
- On device A, install the plugin, fill in server settings, run Sync now to seed the server.
- On device B (empty vault), install the plugin and fill in the same server settings.
- On device B, run Vault Bridge: Pull from server. The vault gets populated.
- From now on, run Sync now on either device. They stay in sync.
Conflicts in practice
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 original
You decide what to do (merge, keep one, etc.) in your editor.
Recovery scenarios
"I deleted a file directly on the server via SSH"
The manifest still has the entry, so the next sync sees nothing changed. To propagate the deletion:
- Run Vault Bridge: Rebuild remote manifest — re-walks the server filesystem and rewrites the manifest based on reality.
- Run Sync now — the diff now sees "remote deleted file", proposes deleting locally (bulk-delete modal will appear if many files).
"I wiped the server folder by accident"
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:
- Force push from local — re-upload every file from this device, rebuild the manifest.
- Reset snapshot — clear this device's snapshot so the next sync treats local files as fresh additions.
- Cancel — investigate before doing anything.
This blocks the catastrophic "treat empty manifest as N deletions" path before the bulk-delete modal even runs.
"Sync says lock is held by another device"
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).
"Local index seems wrong"
Run Rebuild local index — full re-scan and re-hash. Cheap on small vaults.
Excluding files
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
Security notes
- Password and key passphrase are encrypted at rest with AES-256-GCM. The encryption key is generated on first run and stored in
<vault>/.obsidian/plugins/vault-bridge-sftp/state/secret.key. The state directory is hard-excluded from sync, so even ifdata.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. - Host key pinning (TOFU). On the first connection to a host, the server's SHA-256 host-key fingerprint is recorded in
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. - No end-to-end encryption of file content. Files on the server are stored as-is. If you have sensitive notes, encrypt the server's filesystem.
.sync/directory is world-readable by default. Lock down permissions if you store the vault on a multi-user server.
Limitations
- Desktop only (
isDesktopOnly: true). Mobile Obsidian cannot open raw SSH sockets. - No rename detection by hash yet. A renamed large file is currently re-uploaded. Planned for a future release.
- Conflict resolution by mtime assumes device clocks are roughly in sync.
- Single SFTP connection per sync. Operations are pipelined over one SSH channel — concurrent file transfers hide per-file RTT, but very large files share a single TCP window. Configurable via "Concurrent transfers" (default 8).
- External edits to server files (outside the plugin) require Rebuild remote manifest to re-establish consistency.
Development
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
License
MIT — see LICENSE.