Skip to content

Self Hosted Private Sync

Self-hosted, end-to-end encrypted live sync for Obsidian. Your notes sync through a server you run yourself. Notes, attachments and file names are encrypted on your device, and the server never receives the key. The plugin is built for every platform Obsidian runs on, desktop and mobile. There is no subscription and no account anywhere else.

Something not working? → Troubleshooting

Every page says who it is written for on its first line, and the navigation is grouped the same way.

Use obsync

For the person at the device: task first, what you see and what to do.

I want to… Go to
Choose how my devices reach my server Choose your setup
Set up everything on my home network, with every phone screen Same network, step by step
Install or update the plugin Install the plugin
Set up my first device and pair the others Quickstart
Know what the status bar and the commands mean Daily use
Know what a setting does Settings
Deal with a conflict copy Conflicts
Fix a problem Troubleshooting
Get back in after losing a device Recovery

Run a server

For the person operating it. Any host you control, any reverse proxy, VPN or tunnel you trust; Cloudflare is one optional choice.

I want to… Go to
Run it with Docker or Compose Docker and Compose
Run it on Kubernetes Kubernetes and the chart reference
Reach it away from home Reaching it from outside your LAN
Use Cloudflare Cloudflare (optional)
Back it up and upgrade it Back up the two volumes and upgrade by digest
See its devices and storage The dashboard
Understand its volumes and every storage refusal Storage and durability
Wipe it and start again Purging a server

Trust and privacy

I want to… Go to
Know what the plugin touches What this plugin accesses
Know what is encrypted and what the server can see Threat model
Know what the dashboard defends The dashboard's threat model
Report a vulnerability Security policy

Internals

For contributors and reviewers: Architecture, Protocol, Releases, CI map, the device validation plan and its runs, Benchmarks, Platform onboarding, Screenshot conventions and Translations.

This site is a rendering of the docs/ folder of snaraj/obsync. Every page here is a Markdown file in that repository, reviewed in a pull request like any other file, and readable without this site. A few documents live at the root of the repository rather than in docs/ — the README, the changelog, the security policy, the contributing guide, the agent contract, and the Helm chart's own README — and the navigation links to them where they are instead of keeping a second copy here.

Where things live

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, served by the server from OBSYNC_DASHBOARD_DIR
chart/ Helm chart
deploy/compose/ The Compose deployment: the server and a Caddy TLS front
scripts/ CI gates, contract suites and validation probes
docs/ This site

Start with AGENTS.md (the contract) and Architecture.

Building this site

From a checkout, on any machine with a container runtime:

docker run --rm --platform linux/amd64 -v "$PWD:/repo" -w /repo \
  python:3.12-slim sh -c 'pip install --require-hashes --no-deps \
  --only-binary=:all: -r docs/requirements.txt && mkdocs build --strict \
  && python3 -B scripts/ci/site_origins.py strip site \
  && python3 -B scripts/ci/site_origins.py assert site'

The last two commands are the same ones .github/workflows/docs-site.yml runs before it uploads anything, and they are a pair. The vendored theme bundle carries loads to other hosts whatever mkdocs.yml says -- two script injections and the addresses it builds to ask a code-hosting API about this repository -- so strip rewrites the injections into the no-op their own else branch already is and neutralises every other address a script carries. assert then reads the OUTPUT and refuses anything left: HTML through a parser (every src, srcset candidate, poster, data, action, formaction, loading link and meta refresh, in any case and any quoting, protocol-relative addresses included), stylesheets for url() and @import, scripts for every string and template literal that carries an address, and an output too thin to have been judged at all. A link a reader may CLICK is a navigation rather than a load: those are counted and the count is printed. Requirement 1 admits no third-party runtime dependency, and a setting in mkdocs.yml is not evidence about the bytes a reader downloads.

The container is not decoration. docs/requirements.txt pins every package by exact version AND by the sha256 of the exact wheel, and a wheel's sha256 is a fact about ONE file: the closure is resolved and hashed for CPython 3.12 on linux/amd64, the interpreter and platform .github/workflows/docs-site.yml pins. --require-hashes therefore refuses a venv on another interpreter — and so, before it, does --only-binary=:all:, because several of these packages publish no wheel at all for a newer Python. Running the pinned image is how a reader gets the bytes CI gets.

mkdocs.yml at the repository root carries the navigation and the reasoning behind it. .github/workflows/docs-site.yml runs the same install and the same mkdocs build --strict on every pull request, and deploys the result to GitHub Pages on pushes to main.