Samuel Naranjo97 downloadsSelf-hosted, end-to-end encrypted live sync for your own obsync server.
Self-hosted, end-to-end encrypted live sync for Obsidian. One dependency-free Rust binary with a built-in dashboard, plus an Obsidian plugin. Files of any size, bounded only by your disk. No subscription, no third-party service, no crates, no npm packages.
Listed in Obsidian's community plugin directory as Self Hosted Private
Sync (plugin id obsync-private-sync): install it from Settings →
Community plugins → Browse, on every platform Obsidian runs on. Device
acceptance for 1.0.0 is a separate result and is planned in
docs/validation.md.
[!IMPORTANT] This plugin syncs to a server you run. There is no hosted service and no account with anyone but yourself: without your own
obsyncdreachable over HTTPS, the plugin has nothing to sync to.
[!IMPORTANT] Back up your vault before the first sync, and keep the 24-word recovery phrase somewhere other than the device that generated it. The server stores ciphertext only and cannot recover a vault for you.
[!IMPORTANT] Do not run this plugin alongside another sync solution on the same vault — Obsidian Sync, a file-syncing cloud folder, or another sync plugin. Two writers on one vault produce conflicts neither of them can reconcile.
The path this release was validated on, from an empty vault to two devices in sync. Each step is written out in full under Get syncing below, and all five assume your own server is already running.
Install from Community plugins. In Settings → Community plugins → Browse, search for Self Hosted Private Sync and select Install, then Enable — the same way every other Obsidian plugin arrives, on every platform.

Point it at your server and set it up. Open the plugin's settings tab, set Server URL to your own server, choose and save which folders this device syncs, then paste your setup token under First-time setup.

Keep the recovery phrase. Setup generates the vault key on this device and shows a 24-word phrase once: write it down and keep it somewhere other than this device, because the server holds ciphertext only and cannot recover a vault for you.

Pair a second device with a one-time code. Run Pair a new device on the first device, enter the code it shows on the second within ten minutes, and approve the device by name — the vault key travels encrypted under a pairing secret the server never sees.

Edit on either device and watch it land. Type in a note on one device and it appears on the other within seconds, in both directions, with the status bar showing what sync is doing.

The dashboard's device list and its revoke button are documented under See your devices and were not exercised in the 1.0.0 device run recorded in docs/validation-runs/2026-09-14.md.
Run your own server, then install Self Hosted Private Sync from Obsidian’s Community Plugins browser on each device and pair them. Signing in to Obsidian does not authorize self-hosted sync: pair each device once, then sync runs automatically. The plugin needs an account on your own server. There is no Self Hosted Private Sync subscription or hosted account. Obsidian uses its directory and GitHub to install and update the plugin; encrypted sync uses only the server and optional network provider you configure.
The server speaks plain HTTP on port 8080 and must sit behind a TLS terminator: Obsidian on iOS and Android refuses plain HTTP. Which terminator is your choice, and it is the one deployment decision that changes who else is on the path:
| Where you put it | Good for | Notes |
|---|---|---|
| LAN or VPN, with a certificate your devices trust | everything, and the right place for a bulk first sync | HTTPS is required on mobile, so the trusted certificate is not optional |
| An HTTPS reverse proxy on hardware you own | a permanent public endpoint | you own the terminator, so you own its terms |
| A tunnel provider on a public hostname | reaching the server with no inbound port | read the provider's terms on sustained large transfers, and do the bulk first sync on the LAN |
A tunnel is one supported transport, not the foundation. Whatever terminates
TLS reads your credentials -- the device secret at pairing, the dashboard
session cookie -- and never your notes: every chunk and manifest is encrypted
on the device, and no key that decrypts them ever crosses the wire
(docs/architecture.md section 2.1). "Files of any size" is a promise about
this server; a provider on the path has its own terms.
Deploy by digest, never by tag. The image and chart are signed keyless by
this repository’s publisher. New releases carry
obsync-X.Y.Z-release-manifest.json, which names their digests and the
SHA-256 of the plugin bundle and each native installation file. Verify the signature with cosign, read the digest from the verified
payload (it must match the manifest on the Release page), and run exactly
that digest:
cosign verify ghcr.io/snaraj/obsync:v0.1.0 \
--certificate-identity https://github.com/snaraj/obsync/.github/workflows/release-publisher.yml@refs/heads/main \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
docker volume create obsync-blobs
docker volume create obsync-journal
docker run -d --name obsync -p 127.0.0.1:8080:8080 \
-v obsync-blobs:/data/blobs -v obsync-journal:/data/journal \
-e OBSYNC_BLOBS_CAPACITY=250GiB -e OBSYNC_JOURNAL_CAPACITY=4GiB \
-e OBSYNC_PUBLIC_URL=https://sync.example.org \
ghcr.io/snaraj/obsync@sha256:<the digest cosign just verified>
On Kubernetes, install the chart with your storage classes, claim sizes, and
a Secret for OBSYNC_SERVER_KEY; the reference deployment (a single-node
cluster on a Raspberry Pi, reached over private connectivity with no public
hostname) is described in docs/platform-onboarding.md and
docs/architecture.md section 10. The server takes ownership of nothing: each volume must be presented owned by uid 65532 and writable by it, or already hold the server's v1, or the start is refused with reason=unwritable. Static local volumes and hostPath directories: create them as 65532:65532, mode 0700, with root-owned, closed parents and no symlink on the path. A dynamic provisioner that presents a root-owned or world-writable volume root: prepare the backing directory once as the node administrator (chown 65532:65532 and chmod 0700), then start. The chart sets no fsGroup, because a group-writable volume is refused (docs/storage.md, "Volume posture").
At first boot the server mints a setup token and writes it, mode 0600 and
never logged, to v1/setup-token on the journal volume. The token creates
the account once, and it then remains the dashboard's recovery sign-in for
the life of the server (docs/architecture.md section 4.5), so keep it
with the same care as the recovery phrase: anyone holding it can sign in to
the dashboard and revoke devices. Read it without any helper image, from the
container's own volume, running or stopped:
docker cp obsync:/data/journal/v1/setup-token - | tar -xO
On Kubernetes, read v1/setup-token from the journal volume on the node
that holds it. The journal volume also carries the journal itself and, when
OBSYNC_SERVER_KEY is not supplied, the generated server key: back it up as
the sensitive volume it is.
GET /readyz answers {"ready":true} once the server is serving.
Step 1 assumes you already have a TLS terminator. If you have none -- a LAN, a
Pi or a NUC at home, no account with anybody -- deploy/compose is the whole
deployment: the same digest-pinned server with no published port at all,
and a terminator of your own in front of it.
Verify the signature exactly as in step 1, then, from a checkout of this repository:
OBSYNC_IMAGE=ghcr.io/snaraj/obsync@sha256:<digest> \
OBSYNC_HOST=sync.example.org \
OBSYNC_BIND_ADDRESS=192.168.1.10 \
docker compose -f deploy/compose/docker-compose.yml up -d
OBSYNC_HOST is the name your devices will use, and it needs no public
existence at all: a name in your own DNS, a router entry, or a hosts file is
enough, because it only has to resolve on the networks you sync from -- your
LAN, or a VPN back to it. What it DOES need is HTTPS, without exception:
Obsidian on iOS and Android refuses plain HTTP and the plugin speaks nothing
else. Two ways to get a certificate a phone will accept for a private name,
neither of which needs this server reachable from the internet -- what it IS
reachable from is OBSYNC_BIND_ADDRESS below, not either of these:
caddy image, not something this file
does for you.A public hostname, a reachable port 80 and 443, or a tunnel provider are one optional way to reach this server from outside your own network. None of them is a requirement, and nothing below assumes them.
OBSYNC_BIND_ADDRESS is the host address ports 80 and 443 are published
on, and it is the answer to a question the two certificate options above do
not touch. The private name and the certificate authority decide what this
service is CALLED and which devices TRUST it; they decide nothing about who
can reach it -- a client from anywhere can pick the name itself and skip
certificate verification entirely. The bind address decides which of this
host's interfaces accepts connections: a bind address limits the destination
interface, not the source. 127.0.0.1 accepts only connections from this
machine, which is what you want when a VPN terminates here or another
reverse proxy sits in front. A LAN address of this host (192.168.1.10)
accepts every connection that arrives at that address, which is your LAN
and also anything routed to it: a VPN that routes into your LAN, another
subnet your router forwards, or a port forward you set up. So a non-loopback
bind assumes three things you own: your router forwards nothing from the
internet to this host on 80 or 443, a host firewall or router policy limits
sources to the networks you intend, and you know which VPNs route into the
LAN. 0.0.0.0 publishes on every interface this host has, and that IS the
decision to expose it wherever the host is reachable -- legitimate behind a
firewall or a NAT you control, and then the firewall is yours to get right.
Compose refuses to start until you have chosen, because there is no value
here that is safe for everybody.
deploy/compose/docker-compose.yml gives the server
OBSYNC_EDGE=none and trusts forwarded addresses only from the compose
network's own range, which is written in that file beside the network it
belongs to. Ports 80 and 443 on the address you chose are the only ones
opened, and OBSYNC_HTTP_PORT and OBSYNC_HTTPS_PORT move that pair of HOST
ports if something on this machine already holds them -- they default to 80 and
443, and the container ports, the certificate and the name never change with
them.
The setup token is read the same way as in step 1, from the container compose created:
docker cp obsync-obsync-1:/data/journal/v1/setup-token - | tar -xO
scripts/ci/compose-smoke.sh brings this exact file up on every pull request
and proves the path end to end: the bind address required before anything
starts, TLS through the proxy, /readyz truthful, the server itself with no
published port, 80 and 443 published on the chosen address and on nothing
else, the token readable, both containers hardened.
deploy/compose/Caddyfile issues certificates from an authority Caddy
generates on first start, so nothing needs to be reachable from the internet
and you need no domain. The price is that each device must be told to trust
that authority once -- the Obsidian plugin speaks HTTPS only, and on phones
there is no "continue anyway". Export the root certificate:
docker cp obsync-caddy-1:/data/caddy/pki/authorities/local/root.crt - \
| tar -xO > obsync-root.crt
Copy obsync-root.crt to each device and install it:
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain obsync-root.crtcertutil -addstore -f Root obsync-root.crtsudo cp obsync-root.crt /usr/local/share/ca-certificates/obsync-root.crt, then sudo update-ca-certificates. On Fedora and its relatives the directory is
/etc/pki/ca-trust/source/anchors/ and the command is update-ca-trust.deploy/compose/Caddyfile -- a real domain, ports 80 and 443 reachable,
and a certificate every device already trusts. Nothing else changes.Use Obsidian 1.12.4 or newer on each device. Credentials and vault keys use Obsidian's native secret storage; unavailable storage stops setup and sync.
Name: value.Notes, one per line, and click Save on this
device before setup or pairing. An empty selected list syncs no files;
Whole vault retains the existing default. Select the final folders
now: after sync has history, the selection may only narrow. To stage a
first sync within one vault, keep personal files in an excluded folder,
test disposable notes inside the selected folder, then move the personal
files in and run Sync now.Existing installations migrate their own credentials before removing them from plugin data. Keep the vault and recovery phrase intact if a storage error appears. Check Obsidian's secret storage and reload; do not delete the credential reference or repeat server setup. A partially enrolled device still needs approval before an existing recovery phrase can restore sync. A pending pairing dialog does not resume after app restart. Secret storage is shared with other trusted plugins in that vault and is not an OS or plugin isolation boundary. Native restart persistence is a separate validation step.
obsidian://obsync-private-sync/pair?code=... link you can send yourself.On any paired computer, run Open dashboard: it mints a one-time sign-in link to the dashboard, where you see every device (type, address, country, last sign-in, last edit), storage per volume, scrub and garbage-collection state, and installation guidance. Revoke a lost device there or from the Devices list in the plugin settings.
Every command is under Self Hosted Private Sync in the command palette
(plugin/src/main.ts):
| Command | What it does |
|---|---|
| Sync now | Forces one pass instead of waiting for the watcher |
| Show sync status | What the engine is doing, and why it is not doing more |
| Pair a new device | Mints a one-time pairing code on this device |
| Show recovery phrase | Re-displays the 24 words, from this device's own key |
| Restore from history | Browses retained versions and restores one as a copy |
| Show remote-only files | Lists files above this device's ceilings, to fetch on demand |
| Open dashboard | Mints a one-time dashboard sign-in link |
The status bar reads obsync: not paired before pairing, then obsync: idle,
obsync: syncing <n> while n files are in flight, obsync: offline when the
server is unreachable, and obsync: error — <reason> when sync has stopped.
offline. The device cannot reach the server:
check the URL in settings, the certificate, and the private network if the
deployment needs one. Mobile Obsidian refuses plain HTTP entirely.Show sync status repeats it. Credential-storage
failures are covered in
installation trust and distribution..obsidian, .git), symlinked
folders, and anything outside this device's folder selection are excluded by
design — see What syncs and what does not below.Show remote-only files, to fetch on demand.not_ready. The volume posture
and every refusal it can raise are in
docs/storage.md; the protocol is in
docs/protocol.md and the design in
docs/architecture.md.docs/architecture.md section 5..obsidian, .git) and symlinked folders are not synced
in either direction.Open Self Hosted Private Sync: Restore from history in the command palette. Optionally enter part of a filename, select Restart search, then Load next. Versions appear oldest first, including retained content of deleted notes. Each click checks at most 20 records; an empty filtered page can still have more history after it. Select Restore a copy on a content version to create a uniquely named sibling inside the currently selected folder. Deletion markers themselves contain no file bytes.
The original file, unsynced edits and original history remain unchanged. The notice first confirms a local copy and requests ordinary sync; check sync status for upload failures. Device size/budget limits apply to the additional copy. Desktop streams into a temporary file and publishes only to an unoccupied name; a filesystem without that primitive is refused. Mobile buffers the verified file and uses Obsidian's create-only API.
Cancel prevents later work, but Obsidian cannot abort a network request or local create already dispatched. A late create may finish; check any copy path named in an error before retrying. The network API buffers responses before a size check is possible. Reopening history does not start another manual request until the outstanding one settles. These are platform limits, not a claim of power-loss or real-device validation.
| Path | Contents |
|---|---|
crates/obsync-core |
Homegrown primitives: SHA-256, HMAC, HKDF, CRC32, encodings, JSON, HTTP/1.1 |
crates/obsyncd |
The server: storage engine, journal, sync API, dashboard, CLI |
plugin/ |
The Obsidian plugin (TypeScript, WebCrypto, no runtime dependencies) |
dashboard/ |
Static dashboard assets embedded into the server |
chart/ |
Helm chart |
bench/ |
Benchmark harness; LiveSync is the reference to beat |
docs/ |
Architecture, protocol, storage, threat model, validation, onboarding |
Start with AGENTS.md (the contract) and
docs/architecture.md.
MIT. See LICENSE.