Obsidian LiveSync
The path: Foundation → Tailscale → Docker → Obsidian LiveSync
Obsidian: plain markdown files in a plain folder on your machine. No account, no subscription, no lock-in. Its one weakness is that the folder sits on one machine.
Self-hosted LiveSync fixes that with a small database container (CouchDB) on your server. Every note is encrypted on the device, split into chunks, and pushed through your Tailscale front door to every other device. The server stores noise; only your devices hold readable text. Notes everywhere, readable nowhere but by you.
Prerequisites, and I mean it: a foundation, Tailscale HTTPS working, and Docker + Dockge. Plus Obsidian on your main computer, already holding your notes.
The moving parts
Four pieces, one job — your notes, everywhere, readable only by you:
| Piece | What it is | Where it lives |
|---|---|---|
| The vault | Plain .md files — the only human-readable copy |
Each device |
| LiveSync plugin | Splits notes into chunks, encrypts, ships both ways; keeps a local cache so it works offline | Inside each Obsidian |
| The front door | Tailscale, terminating HTTPS with a valid certificate | Your server |
| CouchDB | Stores encrypted chunks; knows nothing about notes | Container, data in a plain folder |
The server never sees a readable word. That's not a feature you enable at the end — it's the design, and it starts with the passphrase you'll set in step three.
1. The database
One stack folder, one app, data outside the box:
mkdir -p /opt/stacks/obsidian && cd /opt/stacks/obsidian
First, open local.ini in nano — it holds the CORS settings that let the Obsidian apps talk to the database, and it must exist before first boot:
[httpd]
enable_cors = true
[cors]
origins = app://obsidian.md,capacitor://localhost,http://localhost
credentials = true
methods = GET,PUT,POST,HEAD,DELETE
headers = accept,authorization,content-type,origin,referer,cache-control,keep-alive,user-agent,if-match,destination,x-csrf-token,x-requested-with
Why CORS at all: the plugin runs as JavaScript inside each Obsidian app's webview, and webviews enforce CORS. Those three origins are the desktop app, the iOS app, and the Android app. One server-side setting covers every device forever.
Generate the database password first — openssl rand -base64 24 — and into your password vault it goes. Then nano compose.yaml:
services:
couchdb:
image: couchdb:3
restart: unless-stopped
environment:
- COUCHDB_USER=admin
- COUCHDB_PASSWORD=<the password you just generated>
ports:
- "127.0.0.1:5984:5984"
volumes:
- ./data:/opt/couchdb/data
- ./local.ini:/opt/couchdb/etc/local.ini
Same doctrine as always: no version: key, restart: unless-stopped, database pinned to a major version (couchdb:3 blocks a surprise 4.x). The port binds to localhost only — nothing listens on your LAN; Tailscale is the sole front door.
docker compose up -d
2. The front door
Now the URL — and read this part before typing, because it's the one thing you must never change later.
If this box serves nothing else, one command and done:
sudo tailscale serve --bg 5984
Your URL is https://server-name.your-tailnet.ts.net.
If Immich (or anything) already lives at that URL, you've just discovered a real rule: one hostname, one root. tailscale serve can't put two apps behind the same name, and serving apps under subpaths breaks them — they load their assets from /. The second app gets its own name. This is the pattern my whole lab runs: one tiny Tailscale sidecar container per app, each with its own name.ts.net — the pattern I teased in the Tailscale guide.
Add this to the same compose.yaml:
ts-notes:
image: tailscale/tailscale:latest
container_name: ts-notes
hostname: notes
environment:
- TS_AUTHKEY=tskey-auth-REPLACE-ME
- TS_SERVE_CONFIG=/config/serve.json
volumes:
- ./ts-notes/state:/var/lib/tailscale
- ./ts-notes/config:/config
- /dev/net/tun:/dev/net/tun
cap_add:
- net_admin
- sys_module
restart: unless-stopped
Then nano ts-notes/config/serve.json:
{
"TCP": {
"443": { "HTTPS": true }
},
"Web": {
"${TS_CERT_DOMAIN}:443": {
"Handlers": {
"/": { "Proxy": "http://couchdb:5984" }
}
}
}
}
The container fills in ${TS_CERT_DOMAIN} itself once it's online, and the certificate is issued automatically. Note the proxy target: the sidecar reaches CouchDB by its compose service name over the docker network — that's why it says http://couchdb:5984, not localhost. (Syntax matches Tailscale's own sidecar examples; their serve docs are the source of truth if the format ever changes.)
For the key: Tailscale console → Settings → Keys → generate an auth key, reusable + pre-authorized. After the container joins, find notes in the console and disable key expiry — the server rule from the Tailscale guide. State persists in ./ts-notes/state, so the key is only needed on first boot; if you ever wipe that folder you'll need a live one (they die at 90 days — the crash-loop gotcha from the same guide).
docker compose up -d
Either way you now have ONE URL — https://notes.your-tailnet.ts.net in the sidecar version. Create the database and prove the door works in one move:
PASS='the password from your compose file'
curl -u admin:$PASS -X PUT https://notes.your-tailnet.ts.net/obsidian
# {"ok":true}
3. The plugin — desktop first
On the machine that already holds your notes: Obsidian → Settings → Community plugins → Browse → Self-hosted LiveSync → Install → Enable. Open its setup wizard and answer:
| Wizard asks | Answer |
|---|---|
| Remote URI | https://notes.your-tailnet.ts.net (your URL from step 2) |
| Username / Password | the COUCHDB_USER / COUCHDB_PASSWORD from compose |
| Database | obsidian |
(Wizard screens drift between plugin versions; the answers don't.)
The connection test goes green, then it asks about encryption. Set a passphrase: long, random, and saved in your password vault before you close that dialog. This is not a login — it's the encryption key, and it never leaves the device. Without it, the server copy is unreadable noise, including to you.
On this first machine — the one holding your notes — let it upload everything. When the plugin's status settles, verify from the server:
curl -s -u admin:$PASS https://notes.your-tailnet.ts.net/obsidian | grep -o '"doc_count":[0-9]*'
Type a sentence in any note, run it again, watch the number tick. That's sync, working. And if you peek at the database in your browser — https://notes.your-tailnet.ts.net/_utils/ — you'll see counts and sizes and zero readable text. That's not a bug. That's the privacy model working.
4. Phones: setup URI, then Fetch
Don't re-type wizard answers on a phone — that's fat-finger city. The plugin moves its own settings:
- On the desktop, command palette (
Ctrl+P) → Self-hosted LiveSync: copy settings as a new setup URI → give the URI its own passphrase (a separate secret from the encryption one — vault both) → send theobsidian://setuplivesync?...string to the phone over Signal. - On the phone: Tailscale ON — no VPN, no sync, feature not bug. Obsidian → new, empty vault → Community plugins → install and enable Self-hosted LiveSync.
- Tap the setup URI. Obsidian opens, asks for the URI passphrase, then asks how to proceed: Fetch. (If tapping does nothing: copy the URI to the clipboard, then command palette → use the copied setup URI.)
- Let the progress bar finish.
Android: battery Unrestricted — the same dance as Immich, or background sync quietly dies. iOS: apps sleep in the background, so Obsidian syncs while it's open; open it, let it settle, move on.
The dialogs, translated once, because they will scare you exactly once:
| You see | It means | You pick |
|---|---|---|
| "How do you want to proceed?" (new device) | First contact with the server | Fetch |
| "Remote database is locked..." | A rebuild was started somewhere and never finished | Reset synchronisation on this device — safe, never touches your files |
| "Remote database has been rebuilt or corrupted..." | The server is newer than this device's checkpoint | Fetch |
Rule of thumb for every LiveSync dialog, forever: Fetch = server wins (always safe for a replica). Rebuild / Keep local = this device wins (only when you truly know better). "Unlock" = not today.
5. Backups
Two things matter, and both are already plain files: the stack folder (compose.yaml + local.ini) and ./data. The clean way to copy a running database is from inside the container:
docker exec couchdb tar czf /tmp/couchdb-$(date +%F).tar.gz -C /opt/couchdb/data .
docker cp couchdb:/tmp/couchdb-$(date +%F).tar.gz .
And the one line that belongs in your restore runbook: the encryption passphrase is required to read anything back. A data folder plus the passphrase equals your entire vault on any device; the folder alone equals noise. The honest restore drill is the same as the photos: stand up a new device, press Fetch, watch the vault appear. A backup you've never restored from is a hope, not a backup.
Gotchas I have personally paid for
CORS set through the API dies with the container. CouchDB will happily accept CORS configuration through its HTTP interface, and everything works — until an update recreates the container and the runtime config evaporates. Symptom: the plugin log spams "Please check CORS settings on the remote database." That's why CORS lives in
local.ini, mounted from your stack folder, from day one. Config in files survives recreation; config inside a running process doesn't.Do vault surgery with Obsidian open. Bulk-deleting or moving hundreds of files with the app closed means the plugin never witnessed the changes — and to the sync engine, unwitnessed deletions never happened. Next launch, the server puts every "deleted" file right back, and you spend an afternoon figuring out which ghost you just fought. The server's file list is the truth; disk edits are proposals.
The passphrase is the whole security model. Drive stolen, backup copied, server seized — whoever holds your chunks holds noise. Lose the passphrase, and the same is true for you. Password vault, verified.
One Obsidian per vault. Two instances open on the same vault on one machine is a self-inflicted conflict factory. And avoid editing the same note on two devices while one of them is offline for days — that's the one real conflict scenario; normal life never trips it.
Deleted a note by accident? Check the LiveSync trash before you panic — deleted notes go to trash by default, not vapor.
Your photos, your disk. Your notes, your server. Next post takes the phone itself off the lease plan: silent.link — mobile data that doesn't start with a copy of your passport.