Feeling generous? Fund my lavish lifestyle 💅.

🔑 445F 8079 96F7 0586 B715 C7B8 A138 06B1 FA2A 676B / RSS

A Blog by Expatriotic

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:

  1. 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 the obsidian://setuplivesync?... string to the phone over Signal.
  2. On the phone: Tailscale ON — no VPN, no sync, feature not bug. Obsidian → new, empty vault → Community plugins → install and enable Self-hosted LiveSync.
  3. 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.)
  4. 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

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.


← Previous: Immich | Next: silent.link →

#privacy #self-hosting #tips