Selfsync

approvedunreviewed

by Masson17

Sync your vault through your own server, with no lost edits: automatic merges, conflict copies, deleted file recovery, file history and optional end-to-end encryption.

★ 1 stars↓ 18 downloadsUpdated 2d agoMIT

selfsync

English · Русский

Sync your Obsidian vault through a server you run yourself. One small Rust binary, data in SQLite and plain files, a plugin for Obsidian on desktop and mobile, and a command-line client for servers and NAS boxes.

The main promise: no edit is ever lost silently.

  • Edits to different parts of a file are merged automatically (diff3).
  • When edits overlap, both versions are kept: the server's in place, yours as a copy next to it.
  • An edit wins over a delete.
  • Files deleted on any device stay in the server's trash, and every file has a revision history.

Also:

  • renames and moves without re-uploading;
  • resumable transfers of large files in both directions;
  • case-insensitive file systems (Windows, macOS, iOS, Android) and Unicode names (NFC/NFD);
  • optional end-to-end encryption of contents and file names;
  • connecting a new device with a link or a QR code;
  • revoking devices.

Before you install the plugin

  • You need your own selfsync server. The plugin does nothing without one; there is no hosted service, account or subscription. Setup is described below.
  • Network use. The plugin talks only to the server address you enter: it uploads and downloads the files of your vault and their metadata (names, sizes, modification times). Without encryption the server stores your notes as they are; with encryption it stores only ciphertext.
  • No telemetry. The plugin sends nothing anywhere else.
  • Direct file system access on desktop. Obsidian's review flags this, so here is why. On desktop the plugin reads and writes the files of your vault through Node's fs module instead of Obsidian's API: a downloaded file replaces the old one atomically and is flushed to disk (fsync), so a crash or power loss never leaves a half-written note, and large attachments are read and hashed in chunks instead of being loaded into memory whole. It only touches paths inside the vault folder (plus Obsidian's trash when a file is deleted). On mobile it uses Obsidian's adapter API.
  • Clipboard. The "Copy link" button in "Connect a new device" writes the one-time connect link to the clipboard. The plugin never reads the clipboard.
  • WebAssembly inside main.js. The sync core is Rust compiled to WebAssembly (crates/selfsync-wasm) and embedded into main.js, which is minified. Nothing is obfuscated or downloaded at runtime: release assets are built from this repository by the release workflow and carry GitHub build provenance attestations.
  • Deleted files go to Obsidian's trash (system or .trash/, as configured in Obsidian), never deleted permanently.

How it works

 Obsidian (desktop, mobile)         folder on a server/NAS
 ┌──────────────────────────┐       ┌──────────────────┐
 │ plugin (TypeScript)      │       │ selfsync-cli     │
 │  I/O and UI              │       │  tokio + std::fs │
 │ ┌──────────────────────┐ │       │ ┌──────────────┐ │
 │ │ sync core (WASM)     │ │       │ │ sync core    │ │
 │ └──────────────────────┘ │       │ └──────────────┘ │
 └────────────┬─────────────┘       └────────┬─────────┘
              │      HTTP + protobuf         │
              └──────────────┬───────────────┘
                     ┌───────┴────────┐
                     │ selfsync       │  SQLite (metadata, history)
                     │ (server)       │  + content-addressed blobs
                     └────────────────┘

The sync logic is written once, in the selfsync-core crate. The plugin and the command-line client only carry out its actions: read a file, write a file, send a request. Design documents (in Russian): architecture, protocol, decisions.

Server

Choosing a mode

The server is a single selfsync binary that can run in several ways. They differ in how much they cost while nobody is syncing and how fast an edit on one device shows up on another.

ModeProcess between syncsHow clients learn about changesGood for
CGI (Caddy, nginx + fcgiwrap)none, one process per request (~1 ms start)polling: every 15 s while Obsidian is active, backing off to 5 min when idleshared hosting, minimal resources
socket (systemd socket activation)started by the first request, exits after 10 min idlelong-poll /v1/wait, nearly instantyour own VPS: fast and almost free when idle
serve (Docker, plain process)always runninglong-pollDocker, home server
serve + Sablier (Docker on demand)the proxy starts the container, Sablier stops itpollingDocker with memory savings
Podman Quadletlike socket, in a containerlong-pollrootless containers

Long-poll versus starting on demand is a trade-off. Long-poll keeps a request open while Obsidian is in the foreground, which in socket mode keeps the process alive until 10 minutes after every device goes to the background. If you would rather have no process almost all the time, turn off "Server is always running" in the plugin: clients will poll instead, and changes from other devices will arrive up to one polling interval later. Keep it off for CGI, where long-poll is impossible (the client detects this and switches to polling on its own).

Installing the binary

Static binaries (musl, x86_64 and aarch64) are published in server-X.Y.Z releases together with SHA256SUMS and build provenance attestations (the X.Y.Z releases hold only the Obsidian plugin):

sha256sum -c SHA256SUMS --ignore-missing
gh attestation verify selfsync-x86_64-unknown-linux-musl --repo Darkildo/selfsync
sudo install -m 0755 selfsync-x86_64-unknown-linux-musl /usr/local/bin/selfsync

From source: cargo build --release -p selfsync-server (binary at target/release/selfsync).

systemd: start on demand (socket)

sudo cp deploy/systemd/selfsync.{socket,service} deploy/systemd/selfsync-sweep.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now selfsync.socket selfsync-sweep.timer

Put a reverse proxy with TLS in front of the server. Ready-made configs: deploy/caddy/Caddyfile.socket and deploy/nginx/selfsync-proxy.conf. The unit runs with DynamicUser, data lives in /var/lib/selfsync. Run admin commands like this:

sudo systemd-run --pipe -p DynamicUser=yes -p User=selfsync -p StateDirectory=selfsync \
  /usr/local/bin/selfsync token add --vault notes --name laptop

CGI

deploy/caddy/Caddyfile.cgi (Caddy with the caddy-cgi plugin) or deploy/nginx/selfsync-cgi.conf (nginx + fcgiwrap). CGI mode is detected from GATEWAY_INTERFACE. The data directory must belong to the user CGI runs as, and admin commands must run as that user too.

Docker

cd deploy
mkdir -p data && sudo chown 65532:65532 data   # bind mount, not a named volume
docker compose --profile always up -d           # or --profile ondemand (Sablier)
docker compose run --rm selfsync token add --vault notes --name laptop

The image is based on distroless/static: no shell, read-only file system, unprivileged process. Data is kept in ./data through a bind mount rather than a named volume, because docker compose down -v deletes named volumes together with all your notes.

Podman Quadlet

deploy/quadlet/ runs the container with socket activation under rootless Podman.

Configuration

Environment variables (or TOML via --config; variables take precedence):

VariableDefaultMeaning
SELFSYNC_DATA_DIR$STATE_DIRECTORY or /var/lib/selfsyncdata directory
SELFSYNC_PUBLIC_URLfrom request headersexternal address, used in connect links
SELFSYNC_IDLE_TIMEOUT10m (socket)exit after idling, 0 means never
SELFSYNC_MAX_BLOB_SIZE512 MiBlargest file
SELFSYNC_MAX_BODY_SIZE16 MiBrequest body (metadata, upload chunk); keep in line with the proxy
SELFSYNC_LOGinfolog level (file names never reach the log)

Admin commands

selfsync token add --vault notes --name laptop   # device token (the vault is created on first use)
selfsync link --vault notes --name phone         # one-time link + QR code in the terminal
selfsync token list | revoke --name phone
selfsync vault list
selfsync import --vault notes --from ~/Notes     # upload an existing folder
selfsync backup /backups/selfsync-$(date +%F)    # consistent copy while running
selfsync gc --vault notes                         # garbage collection plan; --yes to run it
selfsync sweep                                    # erase deleted files older than the retention window (timer)
selfsync healthcheck

Back up with backup: the copy is consistent even while the server is running. To restore, put the directory back. Devices notice that the server went back in time and run a full reconciliation without deleting anything.

Obsidian plugin

Installing

From Obsidian's community plugins (search for "Selfsync"), or by hand: copy main.js, manifest.json and styles.css from a release into <vault>/.obsidian/plugins/selfsync/ and enable the plugin under Settings → Community plugins.

Connecting

  • With a link or QR code. Run selfsync link … on the server, or press "Connect a new device" in the settings of an Obsidian that is already connected. Open the link on the new device (a phone camera reads the QR code): the server page opens Obsidian with the fields filled in.
  • With a code — the "Connect to server" command.
  • With a token — server address and token in the plugin settings.

Day to day

  • Status bar: ✓ synced, ↻ syncing, ⚠ server unreachable, 🔒 password needed, ⛔ sync stopped. Click to sync now (or enter the password, or reconnect).
  • Sync runs 2–3 seconds after the last edit, on startup, when Obsidian returns to the foreground, and on a polling schedule. When Obsidian goes to the background, pending changes are sent right away.
  • Conflicts raise a notice; the "Unresolved conflicts" window lets you keep both versions, yours or the server's.
  • Deleted files — the "Restore deleted files" command opens the server's trash.
  • File history — from the file context menu or a command.

The vault's configuration folder (.obsidian/ by default; settings differ between devices) and .trash/ are not synced by default. Exclusions are configurable.

Mobile limitations

On mobile Obsidian can only read a file whole (readBinary), so very large attachments need as much memory. The default file size limit is 512 MiB, but on a phone it is sensible to keep attachments much smaller. On desktop large files are read in chunks.

Argon2id for the encryption password uses 64 MiB of memory. Modern phones handle it in seconds, but unlocking once per device takes noticeable time.

File names across platforms

Linux and macOS allow ? : * " < > | in file names, Windows and Android storage do not (Windows also reserves CON, NUL, COM1 and the like). Such a file syncs between the devices that can hold it; on the others it is skipped with a notice, and the rest of the vault keeps syncing. The device that creates such a name also shows a warning. Rename the file to get it everywhere.

Command-line client

selfsync-cli syncs a plain folder with the same core: keep a copy of the vault on a server or NAS, or sync a machine without Obsidian.

selfsync-cli connect --dir ~/notes --server https://notes.example.com --code CODE
selfsync-cli run --dir ~/notes            # watch the folder until Ctrl-C
selfsync-cli run --dir ~/notes --once     # one cycle (cron); exit codes: 1 network, 2 password needed, 3 device revoked
selfsync-cli encrypt --dir ~/notes --password-file ~/.notes-pass
selfsync-cli status --dir ~/notes

The client keeps its state in <folder>/.selfsync/ (token and key with mode 0600) and puts deleted files into <folder>/.trash/. To run it as a service: deploy/systemd/user/selfsync-cli@.service, systemctl --user enable --now selfsync-cli@notes.

Encryption

Turn on encryption from the plugin settings or with selfsync-cli encrypt on a vault that is already in use. Existing files are re-uploaded encrypted, then the plaintext copies on the server are erased. The server sees only ciphertext of contents and names, sizes and modification times. The password cannot be recovered: without it the server copy is unreadable (local files on your devices stay). The threat model and what encryption does not hide are in SECURITY.md (in Russian).

Development

cargo test --workspace                                   # core, server, client, sync scenarios
cargo run --release -p selfsync-sim -- --runs 10000      # deterministic simulation with faults
./scripts/build-wasm.sh                                  # core → plugin/pkg (1.5 MB budget)
cargo build -p selfsync-server
cd plugin && npm ci && npm run lint && npm run build && npm test   # plugin: Obsidian lint, build, e2e against the server

A failing simulation run reproduces by seed: selfsync-sim --seed N --runs 1; full trace with SIM_FULL_TRACE=1.

Releases: node scripts/version.mjs set X.Y.Z, commit, then push an annotated tag X.Y.Z (no v prefix — Obsidian requires the tag to equal the manifest version). The workflow builds everything and creates a draft release; publish it after writing the release notes.

crates/
  selfsync-proto   protocol schema (prost + protox, no system protoc)
  selfsync-core    sync core without I/O: paths, blobs, encryption, merge, index, engine
  selfsync-server  server: HTTP, SQLite, blobs, cgi/socket/serve modes, admin commands
  selfsync-wasm    the core for the plugin
  selfsync-cli     command-line client
  selfsync-sim     simulation: clients + the real server + faults; scenario tests
plugin/            Obsidian plugin (TypeScript, esbuild)
deploy/            systemd, Caddy, nginx, Docker, Podman
spec/              plan, architecture, protocol, decisions (in Russian)

License: MIT.

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.