Can I self-host Obsidian Sync?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called Obsidian LiveSync. It takes one prompt, a 1024 MB VPS, and about 90 minutes. That is $5 a month you stop paying Obsidian Sync — $60 a year on the Sync Standard plan, 1 seat assumed.

Why people pay for Obsidian Sync

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Obsidian Sync is the add-on that makes the free app work the way people already assume it does: the same vault on the laptop, the desktop and the phone, encrypted end to end, with dated version history you can walk back through and shared vaults for the people you work with. It is also how Obsidian is funded, and the reason a small team keeps shipping an editor with no account, no telemetry and no upsell inside it. Paying for Sync buys first-party support and somebody else holding the pager, which is a real product and not a tax.

Obsidian Sync plans and list prices
PlanList priceWhat it buys
FreefreeThe Obsidian app itself, free for personal use, with no sign-up. Nothing syncs on this tier.
Sync Standardthe plan this page prices against$5/mo per seatBilled monthly. $4/mo billed annually. One synced vault, 1 GB of total storage, one month of version history.
Sync Plus$10/mo per seatBilled monthly. $8/mo billed annually. Ten synced vaults, 10 GB of total storage with a paid upgrade to 100 GB, twelve months of version history.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: high

Replaced by Obsidian LiveSync

One project, named before the prompt, so you know what you are about to install.

Your Obsidian vault syncs through a CouchDB you run, end-to-end encrypted, with the server storing nothing it can read.

The only option here that is actually Obsidian sync rather than file sync underneath it. It is a community plugin that replicates your vault into a CouchDB you run, with end-to-end encryption you turn on during setup, so the server stores ciphertext and the passphrase never leaves your devices. It merges simple conflicts instead of leaving you two copies of a note, and it works on iOS and Android, which is where every folder-sync answer falls over. What you take on is a database with a certificate pointed at it, and a passphrase nobody can recover for you.

The swap

You're paying

Obsidian Sync

$5/mo · $60/yr

is replaced by

You'd run

Obsidian LiveSync

ONE EVENING · ~90 min to running · 1024 MB RAM

Obsidian Sync Sync Standard · 1 seat assumed · vendor list price · checked 2026-08-06 · source

Before you start

RAM floor
1024 MBfloor from upstream docs — not measured by us yet
Disk
5 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~90 min1–3 hours, through the first backup

The prompt

Two paths to the same Obsidian LiveSync: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

334 lines · 14,992 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Apache CouchDB 3.5.2.1 on that server, reachable at https://<DOMAIN>, behind the
existing Caddy with automatic TLS, as the sync server the Obsidian Self-hosted LiveSync plugin
replicates into.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.

Say this to the user before anything installs. The server half is the only half you can do. The
other half is a community plugin only they can install, inside Obsidian, on every device they
want synchronised, and Obsidian itself is closed-source software this prompt never touches.

CouchDB needs 1024 MB of RAM available and 5 GB free on /srv. The image publishes amd64 and
arm64. Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, say so and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/obsidian-livesync /srv/obsidian-livesync/backups
sudo install -d -m 755 -o 5984 -g 5984 /srv/obsidian-livesync/data
ls -la /srv/obsidian-livesync
```

Assert: `backups` is owned by the login user and `data` by uid `5984`. The CouchDB image creates
a `couchdb` account at uid 5984 and step 4 runs the container as it, so a data directory owned by
anyone else is a container that starts and cannot write a single document.

## 3. Secrets

Two secrets, both generated here. Do not print either, do not repeat them in your summary, and
do not put them in any log line. Hex rather than base64: one gets typed into a settings field on
a phone, and neither wants escaping.

```bash
umask 077
cat > /srv/obsidian-livesync/.env <<EOF
COUCHDB_USER=livesync
COUCHDB_PASSWORD=$(openssl rand -hex 32)
COUCHDB_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/obsidian-livesync/.env
umask 022
ls -l /srv/obsidian-livesync/.env
```

Assert: the file exists with mode `-rw-------`. `COUCHDB_PASSWORD` is the administrator password
and the credential the user types into the plugin on every device. `COUCHDB_SECRET` signs session
cookies; unset, CouchDB invents one at each boot inside the container, which this install does not
keep. CouchDB locks an address out after five failed authentications by default, and that is the
whole of the brute-force protection here.

## 4. compose.yml

```bash
cat > /srv/obsidian-livesync/compose.yml <<'EOF'
# Obsidian LiveSync · the deterministic fallback. Authored by caniselfhostit
# from the upstream documentation, not copied from a repository:
#   couchdb setup ...... https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md
#   settings applied ... https://github.com/vrtmrz/obsidian-livesync/blob/main/utils/couchdb/provision.ts
#   couchdb config ..... https://docs.couchdb.org/en/stable/config/couchdb.html
#   http and cors ...... https://docs.couchdb.org/en/stable/config/http.html
#   image entrypoint ... https://github.com/apache/couchdb-docker/blob/main/3.5.2.1/docker-entrypoint.sh
#
# One service: Apache CouchDB is the entire server side, and the Obsidian plugin
# replicates into it. The `configs` block holds the settings upstream's
# provisioning tool PUTs into /_node/_local/_config, written as a config file
# instead so nothing needs Deno or a script fetched at install time. Three of
# that tool's settings are left out because CouchDB 3.5 no longer acts on them.
# The mounted name sorts before the docker.ini the image writes, which stays the
# file CouchDB rewrites its own runtime changes into.
#
# `user: "5984:5984"` is upstream's own choice; it also keeps the entrypoint from
# chowning mounts at boot, so the data directory is chowned once, in step 2.
#
# Digest read from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  couchdb:
    image: couchdb:3.5.2.1@sha256:b80216f643e99d31df318c740dbc556ac08b56444030ed1d5e6d7b0d4e625213
    container_name: obsidian-livesync-couchdb
    restart: unless-stopped
    user: "5984:5984"
    env_file: /srv/obsidian-livesync/.env
    configs:
      - source: livesync-ini
        target: /opt/couchdb/etc/local.d/10-livesync.ini
    volumes:
      - /srv/obsidian-livesync/data:/opt/couchdb/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8120.
      - "127.0.0.1:8120:5984"

configs:
  livesync-ini:
    content: |
      [couchdb]
      ; Creates _users and _replicator at startup: the single-node equivalent
      ; of the /_cluster_setup call.
      single_node = true
      ; A note and its attachments are one document, and CouchDB defaults to
      ; 8000000 bytes.
      max_document_size = 50000000

      [chttpd]
      ; Nothing anonymous reaches anything but /_up, the health endpoint.
      require_valid_user = true
      require_valid_user_except_for_up = true
      ; The CouchDB default, restated: the limit above is reachable only
      ; while this one stays above it.
      max_http_request_size = 4294967296
      enable_cors = true

      [cors]
      credentials = true
      ; Obsidian desktop, then mobile under Capacitor. CouchDB rejects a
      ; wildcard origin while credentials are on.
      origins = app://obsidian.md,capacitor://localhost,http://localhost
EOF
cd /srv/obsidian-livesync && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. An error naming `content` means the compose plugin predates
v2.23.1, where inline config content arrived; upgrade it rather than rewriting the file.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-obsidian-livesync
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Obsidian LiveSync · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. CouchDB answers at
# the root of that hostname on purpose: upstream documents the subdirectory form
# as needing the proxy path rewritten. Obsidian on a phone refuses a certificate
# it cannot verify, so the automatic TLS here is the reason mobile sync works.

<DOMAIN> {
	# CouchDB sets its own CORS headers for the three Obsidian origins, so
	# nothing here adds or rewrites one: two Access-Control-Allow-Origin
	# headers on one response is a failure a browser reports as a bare CORS
	# error with no detail worth reading.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# No `encode`: replication holds a long-lived streaming response open and
	# moves chunks that are already encrypted.
	#
	# 8120 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8120
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-obsidian-livesync, reload, and report what it objected to.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change
nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. CouchDB's 5984 never reaches the host: compose publishes it as 127.0.0.1:8120, so 8120
stays closed too. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8120 or 5984.

## 7. Start and verify

```bash
cd /srv/obsidian-livesync
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/_up); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/_up
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

Assert all three and print what you received for each. The loop ends printing `200`. The next
line prints exactly `{"status":"ok"}`. The last prints `401`, the security assert here: the root
refuses an anonymous request while `/_up` does not. If any of the three misses, stop, run
`docker compose logs --tail 40 couchdb`, and name the likely cause. A permission error there
points at step 2; a `502` means the container exited; a certificate error means the A record was
too new; a `200` from the root means step 4's config was never applied, stop and re-check it. A
running container is not success.

Now create the database the plugin replicates into. The credential goes to curl on standard
input, so it never appears in the process list:

```bash
cd /srv/obsidian-livesync
set -a; . ./.env; set +a
printf 'user = "%s:%s"\n' "$COUCHDB_USER" "$COUCHDB_PASSWORD" | curl -sS -K - -X PUT https://<DOMAIN>/obsidiannotes
unset COUCHDB_USER COUCHDB_PASSWORD COUCHDB_SECRET
```

Assert: that prints `{"ok":true}`, or `{"error":"file_exists"` on a re-run, which is also fine.
Do not print that file or echo either variable.

STOP: tell the user to read their credentials with
`sudo grep -E 'COUCHDB_USER|COUCHDB_PASSWORD' /srv/obsidian-livesync/.env`, put both in their
password manager, and wait. Do not continue until they confirm.

STOP: tell the user to set up the first device, and wait. Do not continue until they confirm.
Give them these steps in this order. Back up the vault. Turn off Obsidian Sync, iCloud and every
other tool writing to it, because two synchronisers on one vault duplicate and corrupt notes. In
Obsidian: Settings, Community plugins, turn off Restricted mode, Browse, install and enable
`Self-hosted LiveSync`. Select the `Welcome to Self-hosted LiveSync` notice, choose
`I am setting this up for the first time`, confirm. On `Connection Method` choose
`Configure a remote manually`. On `End-to-End Encryption`, enable it and enter a passphrase they
wrote down first: it never reaches this server and nothing here can recover it. Choose `CouchDB`.
Enter the URL `https://<DOMAIN>`, the username and password from the previous step, and the
database name `obsidiannotes`. Select
`Create or connect to database and continue`, then `Restart and Initialise Server`, then
`I Understand, Overwrite Server`, then `Use this device's settings`. Wait for the progress
indicators to clear, then create one ordinary note.

Once they confirm, prove the note arrived:

```bash
cd /srv/obsidian-livesync
set -a; . ./.env; set +a
printf 'user = "%s:%s"\n' "$COUCHDB_USER" "$COUCHDB_PASSWORD" | curl -sS -K - https://<DOMAIN>/obsidiannotes
unset COUCHDB_USER COUCHDB_PASSWORD COUCHDB_SECRET
```

Assert: the response contains `"db_name":"obsidiannotes"` and a `doc_count` greater than 0. That
number is the product working end to end, a note that went from a text editor into a database on
the user's own server. If it is still 0, the plugin never connected: have them reopen its
settings and read the connection error there, which names the cause better than any log here.

## 8. First backup and restore

Take the backup now, while the only thing in that database is a test note.

```bash
cd /srv/obsidian-livesync
docker compose stop
sudo tar -czf /srv/obsidian-livesync/backups/obsidian-livesync-$(date +%F).tar.gz -C /srv/obsidian-livesync data .env compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/obsidian-livesync/backups/
```

Assert: the archive exists and is non-empty. Print its size. The container stops for the copy
because a tar of a database mid-write is not a backup. Downtime is about ten seconds.

A backup on the same disk as the data is not a backup either. Run this from the user's machine,
not the server:

```bash
mkdir -p ~/backups/obsidian-livesync
scp vps:/srv/obsidian-livesync/backups/*.tar.gz ~/backups/obsidian-livesync/
```

To restore: `docker compose down`, `sudo rm -rf /srv/obsidian-livesync/data`, untar the archive
into /srv/obsidian-livesync, `sudo chown -R 5984:5984 /srv/obsidian-livesync/data`, then
`docker compose up -d`. Tell the user the part that is easy to miss: with end-to-end encryption
on, every document in that archive is ciphertext and the passphrase is neither in it nor on this
server, so a restored database without it is a folder of noise. That passphrase belongs in the
same password manager entry as the CouchDB password, today.

## 9. Updating later

New images are listed at https://hub.docker.com/_/couchdb and the release notes at
https://docs.couchdb.org/en/stable/whatsnew/index.html. Take a backup first, then edit the image
line in /srv/obsidian-livesync/compose.yml to the new tag and its digest:

```bash
cd /srv/obsidian-livesync
docker compose pull
docker compose up -d
docker compose logs --tail 30 couchdb
```

Re-run the `/_up` check from step 7 before calling the update done. The plugin updates
separately, inside Obsidian, on each device, and nothing here pins its version.

## 10. What will probably go wrong

I opened https://<DOMAIN> in a browser expecting a dashboard, got a login box, typed the
credentials, and landed on a page of raw JSON that says `Welcome`. I spent several minutes
convinced Caddy was proxying the wrong thing. It was not: this install ships no web interface, so
a `401` before you log in and a small JSON object afterwards are both correct. The screen that
tells you this worked is inside Obsidian, and the number that proves it is `doc_count`.

## 11. Out of scope

- Do not run upstream's `couchdb-init.sh` or its Deno setup-URI generator. Every setting either
  one applies is already in the compose file, with its source recorded there.
- Do not enable Customisation Sync or Hidden File Sync. Upstream keeps optional features off
  until ordinary note sync is verified, and so does this install.
- Do not set `origins` to `*`. CouchDB refuses a wildcard origin while `credentials` is true,
  and the three listed are the ones Obsidian sends.
- Do not configure SMTP. CouchDB sends no mail, so there is nothing for it to do.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Apache CouchDB 3.5.2.1 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1. This install gives you the server half of Obsidian LiveSync and only
the server half. The other half is a community plugin you install by hand, inside Obsidian, on
every device you want synchronised, and Obsidian itself is closed-source software nothing here
touches. Step 7 is where you do that part, and it is the step that decides whether any of this
worked.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `1024` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
and run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. This matters more here
than on most installs: Obsidian on a phone refuses a connection whose certificate it cannot
verify, so no certificate means no mobile sync at all. Under 1024 MB of RAM, add swap or resize
the box before going on.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/obsidian-livesync /srv/obsidian-livesync/backups
sudo install -d -m 755 -o 5984 -g 5984 /srv/obsidian-livesync/data
ls -la /srv/obsidian-livesync
```

You should see: `backups` owned by you, and `data` owned by `5984` twice.

If you do not: that uid is not arbitrary. The CouchDB image creates a `couchdb` account at uid
5984, and step 4 runs the container as that account, so a data directory owned by you is a
container that starts and then cannot write a single document. If `ls -la` prints a name instead
of the number, some other account on this box already holds 5984; that is fine, it is the same
uid.

## 3. Secrets

Two secrets, both generated here on the server, both written straight into a file only you can
read. Hex rather than base64: one of them gets typed into a settings field on a phone, and
neither wants escaping.

```bash
umask 077
cat > /srv/obsidian-livesync/.env <<EOF
COUCHDB_USER=livesync
COUCHDB_PASSWORD=$(openssl rand -hex 32)
COUCHDB_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/obsidian-livesync/.env
umask 022
ls -l /srv/obsidian-livesync/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you
pasted the lines in separate shells. Run `chmod 600 /srv/obsidian-livesync/.env` and carry on. If
the file already existed from an earlier attempt, this block has now replaced both values, which
is fine before the database exists and a nuisance afterwards: CouchDB takes the administrator
password from the environment when the container is created, so
`docker compose up -d --force-recreate` applies the new one, and every device still holding the
old one reports an authentication failure until you retype it there.

Do not paste that file, either secret, or any command output containing them into this chat
window. Read the two values you need with
`sudo grep -E 'COUCHDB_USER|COUCHDB_PASSWORD' /srv/obsidian-livesync/.env` in your own terminal
and put them in your password manager. `COUCHDB_SECRET` is not one you ever type: it signs
session cookies, and unset CouchDB would invent one at each boot inside the container, which this
install does not keep.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/obsidian-livesync/compose.yml <<'EOF'
# Obsidian LiveSync · the deterministic fallback. Authored by caniselfhostit
# from the upstream documentation, not copied from a repository:
#   couchdb setup ...... https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md
#   settings applied ... https://github.com/vrtmrz/obsidian-livesync/blob/main/utils/couchdb/provision.ts
#   couchdb config ..... https://docs.couchdb.org/en/stable/config/couchdb.html
#   http and cors ...... https://docs.couchdb.org/en/stable/config/http.html
#   image entrypoint ... https://github.com/apache/couchdb-docker/blob/main/3.5.2.1/docker-entrypoint.sh
#
# One service: Apache CouchDB is the entire server side, and the Obsidian plugin
# replicates into it. The `configs` block holds the settings upstream's
# provisioning tool PUTs into /_node/_local/_config, written as a config file
# instead so nothing needs Deno or a script fetched at install time. Three of
# that tool's settings are left out because CouchDB 3.5 no longer acts on them.
# The mounted name sorts before the docker.ini the image writes, which stays the
# file CouchDB rewrites its own runtime changes into.
#
# `user: "5984:5984"` is upstream's own choice; it also keeps the entrypoint from
# chowning mounts at boot, so the data directory is chowned once, in step 2.
#
# Digest read from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  couchdb:
    image: couchdb:3.5.2.1@sha256:b80216f643e99d31df318c740dbc556ac08b56444030ed1d5e6d7b0d4e625213
    container_name: obsidian-livesync-couchdb
    restart: unless-stopped
    user: "5984:5984"
    env_file: /srv/obsidian-livesync/.env
    configs:
      - source: livesync-ini
        target: /opt/couchdb/etc/local.d/10-livesync.ini
    volumes:
      - /srv/obsidian-livesync/data:/opt/couchdb/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8120.
      - "127.0.0.1:8120:5984"

configs:
  livesync-ini:
    content: |
      [couchdb]
      ; Creates _users and _replicator at startup: the single-node equivalent
      ; of the /_cluster_setup call.
      single_node = true
      ; A note and its attachments are one document, and CouchDB defaults to
      ; 8000000 bytes.
      max_document_size = 50000000

      [chttpd]
      ; Nothing anonymous reaches anything but /_up, the health endpoint.
      require_valid_user = true
      require_valid_user_except_for_up = true
      ; The CouchDB default, restated: the limit above is reachable only
      ; while this one stays above it.
      max_http_request_size = 4294967296
      enable_cors = true

      [cors]
      credentials = true
      ; Obsidian desktop, then mobile under Capacitor. CouchDB rejects a
      ; wildcard origin while credentials are on.
      origins = app://obsidian.md,capacitor://localhost,http://localhost
EOF
cd /srv/obsidian-livesync && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: an error naming `content` means your compose plugin predates v2.23.1, where inline
config content arrived. Upgrade the plugin rather than rewriting the file as a bind mount, since
a bind-mounted ini under /opt/couchdb is a permission problem you do not need.
`env file /srv/obsidian-livesync/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/obsidian-livesync/compose.yml` and paste again in one go.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-obsidian-livesync
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Obsidian LiveSync · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. CouchDB answers at
# the root of that hostname on purpose: upstream documents the subdirectory form
# as needing the proxy path rewritten. Obsidian on a phone refuses a certificate
# it cannot verify, so the automatic TLS here is the reason mobile sync works.

<DOMAIN> {
	# CouchDB sets its own CORS headers for the three Obsidian origins, so
	# nothing here adds or rewrites one: two Access-Control-Allow-Origin
	# headers on one response is a failure a browser reports as a bare CORS
	# error with no detail worth reading.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# No `encode`: replication holds a long-lived streaming response open and
	# moves chunks that are already encrypted.
	#
	# 8120 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8120
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-obsidian-livesync /etc/caddy/Caddyfile`,
reload, and paste again. The most common cause is forgetting to replace `<DOMAIN>`, which Caddy
reports as an unrecognised directive on the line holding the angle brackets.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8120` or `5984`.

If you do not: delete anything for `8120` or `5984` with `sudo ufw delete allow 8120`. CouchDB's
own port is 5984 inside the container and the compose file publishes it as 127.0.0.1:8120, so
neither number belongs in a firewall rule. 80/tcp answers the ACME challenge and redirects to
HTTPS, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has turned it off since, and `sudo ufw enable` puts it back.

## 7. Start and verify

```bash
cd /srv/obsidian-livesync
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/_up); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/_up
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

You should see, in order: the loop reaching `200`, then exactly `{"status":"ok"}`, then `401`.

If you do not: the `401` is the one worth understanding. It means CouchDB is up and refusing a
request that carries no credential, which is what this install configured, so seeing it is good
news. A `200` in its place would mean the server is open to the internet and you should stop and
re-check step 4. If the loop never reaches `200`, run `docker compose logs --tail 40 couchdb`; a
permission error there is step 2 done wrong, a `502` from Caddy means the container exited, and a
certificate error means the A record is too new and Caddy is still retrying.

Now create the database the plugin will replicate into. The credential is fed to curl on standard
input rather than on the command line, so it never shows up in `ps` or in your shell history:

```bash
cd /srv/obsidian-livesync
set -a; . ./.env; set +a
printf 'user = "%s:%s"\n' "$COUCHDB_USER" "$COUCHDB_PASSWORD" | curl -sS -K - -X PUT https://<DOMAIN>/obsidiannotes
unset COUCHDB_USER COUCHDB_PASSWORD COUCHDB_SECRET
```

You should see: `{"ok":true}`. On a second run you get `{"error":"file_exists"...}`, which is
also fine.

If you do not: `{"error":"unauthorized"...}` means the `.env` you sourced is not the one CouchDB
started with, so `docker compose up -d --force-recreate` and try again. A curl error about `-K`
means an old curl; the flag has been there for decades, so more likely the leading `printf` line
did not get pasted.

Now the half only you can do. In Obsidian, in this order:

1. Back up the vault, and turn off Obsidian Sync, iCloud and anything else writing to it. Two
   synchronisers on one vault duplicate and corrupt notes, and upstream says so twice.
2. Settings, Community plugins, turn off Restricted mode, Browse, install and enable
   `Self-hosted LiveSync`.
3. Select the `Welcome to Self-hosted LiveSync` notice, choose
   `I am setting this up for the first time`, and confirm.
4. On `Connection Method` choose `Configure a remote manually`.
5. On `End-to-End Encryption`, enable it and enter a passphrase you have written down first.
   That passphrase never reaches the server, and nothing on the server can recover it.
6. Choose `CouchDB`. Enter the URL `https://<DOMAIN>`, the username and password from step 3, and
   the database name `obsidiannotes`.
7. Select `Create or connect to database and continue`, then `Restart and Initialise Server`,
   then `I Understand, Overwrite Server`, then `Use this device's settings`.
8. Wait for the progress indicators to clear, then create one ordinary note.

Then prove the note arrived:

```bash
cd /srv/obsidian-livesync
set -a; . ./.env; set +a
printf 'user = "%s:%s"\n' "$COUCHDB_USER" "$COUCHDB_PASSWORD" | curl -sS -K - https://<DOMAIN>/obsidiannotes
unset COUCHDB_USER COUCHDB_PASSWORD COUCHDB_SECRET
```

You should see: a JSON object containing `"db_name":"obsidiannotes"` and a `doc_count` greater
than 0. That number is the whole product working end to end.

If you do not: a `doc_count` of 0 means the plugin never connected. Reopen its settings in
Obsidian and read the connection error there, which names the cause better than any log on the
server will. There is no first screen to look at in a browser: `https://<DOMAIN>/` asks for the
credentials and then shows a small JSON object saying `Welcome`, and that is all CouchDB has.

## 8. First backup and restore

Take the backup now, while the only thing in that database is a test note.

```bash
cd /srv/obsidian-livesync
docker compose stop
sudo tar -czf /srv/obsidian-livesync/backups/obsidian-livesync-$(date +%F).tar.gz -C /srv/obsidian-livesync data .env compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/obsidian-livesync/backups/
```

You should see: one archive, a few hundred kilobytes on a fresh install. The service is down for
about ten seconds, because a tar of a database taken mid-write is not a backup.

If you do not: an archive of a few hundred bytes means `data` was empty, so CouchDB never
initialised. Start it, re-run the `/_up` check from step 7, and take the backup again.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/obsidian-livesync
scp vps:/srv/obsidian-livesync/backups/*.tar.gz ~/backups/obsidian-livesync/
```

You should see: one file copied, and it listed by `ls -lh ~/backups/obsidian-livesync/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is a test note:

```bash
cd /srv/obsidian-livesync
docker compose down
sudo rm -rf /srv/obsidian-livesync/data
sudo tar -xzf /srv/obsidian-livesync/backups/obsidian-livesync-$(date +%F).tar.gz -C /srv/obsidian-livesync data
sudo chown -R 5984:5984 /srv/obsidian-livesync/data
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/_up
```

You should see: `{"status":"ok"}`, and your test note still in Obsidian after the plugin
reconnects.

If you do not: the `chown` line is the one people skip, and skipping it gives a container that
starts and logs a permission error. Understand the stakes before you move on: with end-to-end
encryption on, every document in that archive is ciphertext, your passphrase is neither in it nor
on the server, and a restored database without the passphrase is a folder of noise. Put the
passphrase in the same password manager entry as the CouchDB password now, not later.

## 9. Updating later

New images are listed at https://hub.docker.com/_/couchdb and the release notes at
https://docs.couchdb.org/en/stable/whatsnew/index.html. Take a backup first, then edit the
`image:` line in /srv/obsidian-livesync/compose.yml to the new tag and its digest.

```bash
cd /srv/obsidian-livesync
docker compose pull
docker compose up -d
docker compose logs --tail 30 couchdb
```

You should see: the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
`/_up` check from step 7 before you call the update done. The plugin updates separately, inside
Obsidian, on each device, and nothing on the server pins its version.

## 10. What will probably go wrong

I opened https://<DOMAIN> in a browser expecting a dashboard, got a login box, typed the
credentials, and landed on a page of raw JSON that says `Welcome`. I spent several minutes
convinced Caddy was proxying the wrong thing. It was not: this install ships no web interface, so
a `401` before you log in and a small JSON object afterwards are both correct. The screen that
tells you this worked is inside Obsidian, and the number that proves it is `doc_count`.

## 11. Out of scope

- Do not run upstream's `couchdb-init.sh` or its Deno setup-URI generator. Every setting either
  one applies is already in the compose file, with its source recorded there.
- Do not enable Customisation Sync or Hidden File Sync. Upstream keeps optional features off
  until ordinary note sync is verified, and so does this install.
- Do not set `origins` to `*`. CouchDB refuses a wildcard origin while `credentials` is true,
  and the three listed are the ones Obsidian sends.
- Do not configure SMTP. CouchDB sends no mail, so there is nothing for it to do.

324 lines · 14,964 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Apache CouchDB 3.5.2.1 under ~/selfhost/obsidian-livesync, answering at
http://localhost:8120, as the sync server the Obsidian Self-hosted LiveSync plugin replicates
into.

## 1. Preflight

Say both of these to the user before step 2 runs. http://localhost:8120 means this computer and
nowhere else, so what they get is a versioned copy of every note in a database they own, not a
phone that syncs. And the server is the only half this prompt does: the plugin goes in by hand,
inside Obsidian, which is closed-source software this prompt never touches.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. CouchDB needs 1024 MB of RAM available and
5 GB free on the home disk, and the image publishes amd64 and arm64. On macOS and Windows the
memory figure is the host's, out of which Docker Desktop's virtual machine takes its allocation.
If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/obsidian-livesync/backups
ls -la ~/selfhost/obsidian-livesync
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: step 5 keeps the
database in a volume Docker manages, so nothing needs a chown.

## 4. Secrets

Two secrets, generated here. Print neither, and keep both out of your summary and out of any log
line.

```bash
umask 077
cat > ~/selfhost/obsidian-livesync/.env <<EOF
COUCHDB_USER=livesync
COUCHDB_PASSWORD=$(openssl rand -hex 32)
COUCHDB_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/obsidian-livesync/.env
umask 022
ls -l ~/selfhost/obsidian-livesync/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three. `COUCHDB_PASSWORD` is the administrator password the user types into the
plugin; `COUCHDB_SECRET` signs session cookies, and unset CouchDB invents one at each boot inside
the container, which this install does not keep. On Windows those mode bits are advisory and the
boundary is the user's own Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/obsidian-livesync/compose.yml <<'EOF'
# Obsidian LiveSync · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   couchdb setup ... https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md
#   settings ........ https://github.com/vrtmrz/obsidian-livesync/blob/main/utils/couchdb/provision.ts
#   couchdb config .. https://docs.couchdb.org/en/stable/config/http.html
#
# One service. Every path is relative to ~/selfhost/obsidian-livesync/, so one
# file works on macOS, Linux and Windows.
#
# The database is a named volume, not a bind mount: the container runs as uid
# 5984, and making a home-directory bind mount writable by that uid needs root
# on Linux and cannot be expressed at all through Docker Desktop's Windows file
# sharing. A fresh volume inherits the image's own 5984 ownership instead.
#
# The `configs` block is the set of settings upstream's provisioning tool PUTs
# into /_node/_local/_config, written as a config file so nothing needs Deno.
#
# Digest read from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  couchdb:
    image: couchdb:3.5.2.1@sha256:b80216f643e99d31df318c740dbc556ac08b56444030ed1d5e6d7b0d4e625213
    container_name: obsidian-livesync-couchdb
    restart: unless-stopped
    user: "5984:5984"
    env_file: ./.env
    configs:
      - source: livesync-ini
        target: /opt/couchdb/etc/local.d/10-livesync.ini
    volumes:
      - couchdb-data:/opt/couchdb/data
    ports:
      # Loopback only: no other device on the wifi can reach 8120.
      - "127.0.0.1:8120:5984"

configs:
  livesync-ini:
    content: |
      [couchdb]
      ; Creates _users and _replicator at startup: the single-node equivalent
      ; of the /_cluster_setup call.
      single_node = true
      ; A note and its attachments are one document, and CouchDB defaults to
      ; 8000000 bytes.
      max_document_size = 50000000

      [chttpd]
      ; Nothing anonymous reaches anything but /_up, the health endpoint.
      require_valid_user = true
      require_valid_user_except_for_up = true
      ; The CouchDB default, restated: the limit above is reachable only
      ; while this one stays above it.
      max_http_request_size = 4294967296
      enable_cors = true

      [cors]
      credentials = true
      ; Obsidian desktop, then mobile under Capacitor. CouchDB rejects a
      ; wildcard origin while credentials are on.
      origins = app://obsidian.md,capacitor://localhost,http://localhost

volumes:
  couchdb-data:
EOF
cd ~/selfhost/obsidian-livesync && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. An error naming `content` means the compose plugin predates
v2.23.1; update Docker Desktop and run this step again.

## 6. Nothing is public

No reverse proxy and no certificate: there is no public name to certify, browsers treat
http://localhost as a secure context anyway, and upstream states plain HTTP suits a trusted local
connection from a desktop device. No firewall rule either, because nothing is published beyond
loopback: 8120 is bound to 127.0.0.1, and the user's phone is locked out like everyone else's.
Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/obsidian-livesync/compose.yml
```

Assert: one line, `- "127.0.0.1:8120:5984"`.

## 7. Start and verify

```bash
cd ~/selfhost/obsidian-livesync
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8120/_up); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8120/_up
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8120/
```

Assert all three and print what you received for each. The loop ends printing `200`, the next
line prints exactly `{"status":"ok"}`, and the last prints `401`, the security assert here: the
root refuses a request with no credential while `/_up` does not. If any misses, stop, run
`docker compose logs --tail 40 couchdb`. If `port is already allocated` came back, find what
holds 8120 (`lsof -nP -iTCP:8120 -sTCP:LISTEN`, or `netstat -ano | findstr :8120` on Windows) and
stop until the user frees it. A running container is not success.

The plugin creates the database itself in the next step.

STOP: tell the user to read their credentials with
`grep -E 'COUCHDB_USER|COUCHDB_PASSWORD' ~/selfhost/obsidian-livesync/.env`, put both in their
password manager, and wait. Do not continue until they confirm.

STOP: tell the user to set up Obsidian, and wait. Do not continue until they confirm. Steps, in
this order. Back up the vault, then turn off Obsidian Sync, iCloud and every other tool writing
to it, because two synchronisers on one vault duplicate and corrupt notes. In Obsidian: Settings,
Community plugins, turn off Restricted mode, Browse, install and enable `Self-hosted LiveSync`.
Select the `Welcome to Self-hosted LiveSync` notice, choose `I am setting this up for the first
time`, confirm. On `Connection Method` choose `Configure a remote manually`. On
`End-to-End Encryption`, enable it and enter a passphrase they wrote down first: it never reaches
the database and nothing here can recover it. Choose `CouchDB`, enter `http://localhost:8120`,
the credentials from the previous step, and the database name `obsidiannotes`. Select
`Create or connect to database and continue`, then `Restart and Initialise Server`, then
`I Understand, Overwrite Server`, then `Use this device's settings`. Wait for the progress
indicators to clear, then create one note.

Once they confirm, prove the note arrived:

```bash
cd ~/selfhost/obsidian-livesync
set -a; . ./.env; set +a
printf 'user = "%s:%s"\n' "$COUCHDB_USER" "$COUCHDB_PASSWORD" | curl -sS -K - http://localhost:8120/obsidiannotes
unset COUCHDB_USER COUCHDB_PASSWORD COUCHDB_SECRET
```

Assert: the response contains `"db_name":"obsidiannotes"` and a `doc_count` above 0. If it is
still 0 the plugin never connected: have them reopen its settings and read the error there.

## 8. First backup and restore

Two artifacts under backups/: the database, streamed out of its volume by `docker cp`, and the
two files that rebuild the service around it.

```bash
cd ~/selfhost/obsidian-livesync
docker compose stop
docker cp -a obsidian-livesync-couchdb:/opt/couchdb/data - | gzip > backups/livesync-data-$(date +%F).tar.gz
tar -C ~/selfhost/obsidian-livesync -czf backups/livesync-config-$(date +%F).tar.gz compose.yml .env
docker compose start
ls -lh backups/
```

Assert: both files exist and are non-empty. Print both sizes. The service stops for the copy
because a copy of a database mid-write is not a backup; downtime is about ten seconds.

Both archives sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination off this computer, a folder their sync service watches
or a USB stick, and copy both there with `cp`. In Git Bash a Windows drive is `/d/Backups`, not
`D:\Backups`. Assert: the user confirms both files are listed there.

To restore: untar the config archive into ~/selfhost/obsidian-livesync so compose.yml and .env
are back first, then `docker compose down -v`, the one place `-v` belongs because it drops the
old volume on purpose, then `docker compose create`, then
`gunzip -c backups/<the data archive> | docker cp -a - obsidian-livesync-couchdb:/opt/couchdb`,
then `docker compose up -d` and step 7's `/_up` check. With end-to-end encryption on, every
document in that archive is ciphertext and the passphrase is not in it, so a restored database
without it is a folder of noise. It belongs in the same password manager entry as the CouchDB
password.

## 9. Updating later

New images are listed at https://hub.docker.com/_/couchdb, with release notes at
https://docs.couchdb.org/en/stable/whatsnew/index.html. Back up first, then edit the image line
in the compose file to the new tag and digest:

```bash
cd ~/selfhost/obsidian-livesync
docker compose pull
docker compose up -d
docker compose logs --tail 30 couchdb
```

Re-run step 7's `/_up` check before calling this done. The plugin updates separately, inside
Obsidian, and nothing here pins it.

## 10. What will probably go wrong

I rebooted, opened Obsidian, and watched LiveSync report that it could not reach the database.
Nothing was broken: Docker Desktop had not started with the session, so nothing was listening on
8120 and every edit queued locally until it did. `restart: unless-stopped` only acts once the
Docker daemon is up. Turn on Docker Desktop's start-at-login setting, and after a reboot run
`docker compose up -d` here before concluding anything is broken.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8120 to 0.0.0.0 so a phone can reach it. That puts a database holding every note
  on every network the user joins, and Obsidian on a phone refuses plain HTTP anyway.
- Do not run upstream's `couchdb-init.sh` or its Deno setup-URI generator. Every setting they
  apply is in the compose file, with its source recorded there.
- Do not enable Customisation Sync or Hidden File Sync in the plugin.
compose.local.ymlthe services, pinned · local layout65 lines

authored from upstream docs, never pasted · 2,630 bytes

# Obsidian LiveSync · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   couchdb setup ... https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md
#   settings ........ https://github.com/vrtmrz/obsidian-livesync/blob/main/utils/couchdb/provision.ts
#   couchdb config .. https://docs.couchdb.org/en/stable/config/http.html
#
# One service. Every path is relative to ~/selfhost/obsidian-livesync/, so one
# file works on macOS, Linux and Windows.
#
# The database is a named volume, not a bind mount: the container runs as uid
# 5984, and making a home-directory bind mount writable by that uid needs root
# on Linux and cannot be expressed at all through Docker Desktop's Windows file
# sharing. A fresh volume inherits the image's own 5984 ownership instead.
#
# The `configs` block is the set of settings upstream's provisioning tool PUTs
# into /_node/_local/_config, written as a config file so nothing needs Deno.
#
# Digest read from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  couchdb:
    image: couchdb:3.5.2.1@sha256:b80216f643e99d31df318c740dbc556ac08b56444030ed1d5e6d7b0d4e625213
    container_name: obsidian-livesync-couchdb
    restart: unless-stopped
    user: "5984:5984"
    env_file: ./.env
    configs:
      - source: livesync-ini
        target: /opt/couchdb/etc/local.d/10-livesync.ini
    volumes:
      - couchdb-data:/opt/couchdb/data
    ports:
      # Loopback only: no other device on the wifi can reach 8120.
      - "127.0.0.1:8120:5984"

configs:
  livesync-ini:
    content: |
      [couchdb]
      ; Creates _users and _replicator at startup: the single-node equivalent
      ; of the /_cluster_setup call.
      single_node = true
      ; A note and its attachments are one document, and CouchDB defaults to
      ; 8000000 bytes.
      max_document_size = 50000000

      [chttpd]
      ; Nothing anonymous reaches anything but /_up, the health endpoint.
      require_valid_user = true
      require_valid_user_except_for_up = true
      ; The CouchDB default, restated: the limit above is reachable only
      ; while this one stays above it.
      max_http_request_size = 4294967296
      enable_cors = true

      [cors]
      credentials = true
      ; Obsidian desktop, then mobile under Capacitor. CouchDB rejects a
      ; wildcard origin while credentials are on.
      origins = app://obsidian.md,capacitor://localhost,http://localhost

volumes:
  couchdb-data:

agent-readable mirror: /self-host/obsidian-sync.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned64 lines

authored from upstream docs, never pasted · 2,916 bytes

# Obsidian LiveSync · the deterministic fallback. Authored by caniselfhostit
# from the upstream documentation, not copied from a repository:
#   couchdb setup ...... https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md
#   settings applied ... https://github.com/vrtmrz/obsidian-livesync/blob/main/utils/couchdb/provision.ts
#   couchdb config ..... https://docs.couchdb.org/en/stable/config/couchdb.html
#   http and cors ...... https://docs.couchdb.org/en/stable/config/http.html
#   image entrypoint ... https://github.com/apache/couchdb-docker/blob/main/3.5.2.1/docker-entrypoint.sh
#
# One service: Apache CouchDB is the entire server side, and the Obsidian plugin
# replicates into it. The `configs` block holds the settings upstream's
# provisioning tool PUTs into /_node/_local/_config, written as a config file
# instead so nothing needs Deno or a script fetched at install time. Three of
# that tool's settings are left out because CouchDB 3.5 no longer acts on them.
# The mounted name sorts before the docker.ini the image writes, which stays the
# file CouchDB rewrites its own runtime changes into.
#
# `user: "5984:5984"` is upstream's own choice; it also keeps the entrypoint from
# chowning mounts at boot, so the data directory is chowned once, in step 2.
#
# Digest read from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  couchdb:
    image: couchdb:3.5.2.1@sha256:b80216f643e99d31df318c740dbc556ac08b56444030ed1d5e6d7b0d4e625213
    container_name: obsidian-livesync-couchdb
    restart: unless-stopped
    user: "5984:5984"
    env_file: /srv/obsidian-livesync/.env
    configs:
      - source: livesync-ini
        target: /opt/couchdb/etc/local.d/10-livesync.ini
    volumes:
      - /srv/obsidian-livesync/data:/opt/couchdb/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8120.
      - "127.0.0.1:8120:5984"

configs:
  livesync-ini:
    content: |
      [couchdb]
      ; Creates _users and _replicator at startup: the single-node equivalent
      ; of the /_cluster_setup call.
      single_node = true
      ; A note and its attachments are one document, and CouchDB defaults to
      ; 8000000 bytes.
      max_document_size = 50000000

      [chttpd]
      ; Nothing anonymous reaches anything but /_up, the health endpoint.
      require_valid_user = true
      require_valid_user_except_for_up = true
      ; The CouchDB default, restated: the limit above is reachable only
      ; while this one stays above it.
      max_http_request_size = 4294967296
      enable_cors = true

      [cors]
      credentials = true
      ; Obsidian desktop, then mobile under Capacitor. CouchDB rejects a
      ; wildcard origin while credentials are on.
      origins = app://obsidian.md,capacitor://localhost,http://localhost
Caddyfilethe hostname and TLS31 lines

authored from upstream docs, never pasted · 1,335 bytes

# Obsidian LiveSync · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. CouchDB answers at
# the root of that hostname on purpose: upstream documents the subdirectory form
# as needing the proxy path rewritten. Obsidian on a phone refuses a certificate
# it cannot verify, so the automatic TLS here is the reason mobile sync works.

<DOMAIN> {
	# CouchDB sets its own CORS headers for the three Obsidian origins, so
	# nothing here adds or rewrites one: two Access-Control-Allow-Origin
	# headers on one response is a failure a browser reports as a bare CORS
	# error with no detail worth reading.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# No `encode`: replication holds a long-lived streaming response open and
	# moves chunks that are already encrypted.
	#
	# 8120 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8120
}
install.shthe same install, no agent174 lines

authored from upstream docs, never pasted · 7,895 bytes

#!/usr/bin/env bash
# Obsidian LiveSync · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=notes.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md
#   https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/quick_setup.md
#   https://github.com/vrtmrz/obsidian-livesync/blob/main/utils/couchdb/provision.ts
#   https://docs.couchdb.org/en/stable/config/http.html
#
# What this installs is Apache CouchDB, the sync server the Obsidian Self-hosted
# LiveSync plugin replicates into. It cannot install the plugin: that happens by
# hand, inside Obsidian, on every device you want synchronised. The closing
# summary tells you what to do there.
#
# Two secrets are generated here, on this machine: the CouchDB administrator
# password and the session-cookie secret. Both go into /srv/obsidian-livesync/.env
# with mode 600 and neither is ever printed.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/obsidian-livesync}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
DB_NAME="${DB_NAME:-obsidiannotes}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. notes.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; CouchDB wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# The data directory belongs to uid 5984 because the CouchDB image creates a
# couchdb account at that uid and compose.yml runs the container as it.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 755 -o 5984 -g 5984 "$APP_DIR/data"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64 for both: one is typed into a settings field on a phone
# and neither wants escaping. Read the two you need with
#   sudo grep -E 'COUCHDB_USER|COUCHDB_PASSWORD' /srv/obsidian-livesync/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		COUCHDB_USER=livesync
		COUCHDB_PASSWORD=$(openssl rand -hex 32)
		COUCHDB_SECRET=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null \
	|| die "compose rejected the file. An error naming 'content' means the compose plugin predates v2.23.1; upgrade it."

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-obsidian-livesync"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8120 nor 5984 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8120 and 5984 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/_up"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/_up" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/_up answered ${code:-nothing}. Check: docker compose logs --tail 40 couchdb"

curl -sS "https://${DOMAIN_HOST}/_up" | grep -q '"status":"ok"' \
	|| die "/_up answered 200 without status ok. Check: docker compose logs --tail 40 couchdb"

# Every path but /_up must refuse a request that carries no credential. A 200
# here would mean the database is open to the internet.
anon="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
[ "$anon" = "401" ] || die "an unauthenticated request to / returned ${anon}, not 401. Stop and investigate."

# The database the plugin replicates into. The credential goes to curl on stdin,
# so it never appears in the process list.
set -a
# shellcheck disable=SC1091
. "$APP_DIR/.env"
set +a
created="$(printf 'user = "%s:%s"\n' "$COUCHDB_USER" "$COUCHDB_PASSWORD" \
	| curl -sS -K - -X PUT "https://${DOMAIN_HOST}/${DB_NAME}" || true)"
case "$created" in
	*'"ok":true'*|*file_exists*) : ;;
	*) die "creating the ${DB_NAME} database failed: ${created}" ;;
esac
COUCHDB_USER_NAME="$COUCHDB_USER"
unset COUCHDB_USER COUCHDB_PASSWORD COUCHDB_SECRET

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/obsidian-livesync-${STAMP}.tar.gz" -C "$APP_DIR" data .env compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/obsidian-livesync-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	CouchDB is answering at https://${DOMAIN_HOST}/_up and the ${DB_NAME}
	database exists. Nothing syncs yet: the other half is a plugin only you
	can install.

	  1. Read your credentials, in your own terminal, and put them in your
	     password manager. They were not printed here:
	       sudo grep -E 'COUCHDB_USER|COUCHDB_PASSWORD' $APP_DIR/.env
	     The username is ${COUCHDB_USER_NAME}.
	  2. In Obsidian, back up the vault, then turn off Obsidian Sync, iCloud
	     and anything else writing to it. Two synchronisers on one vault
	     duplicate and corrupt notes.
	  3. Settings, Community plugins, turn off Restricted mode, Browse,
	     install and enable Self-hosted LiveSync. Open the welcome notice,
	     choose to set up for the first time, then Configure a remote
	     manually. Turn End-to-End Encryption on and enter a passphrase you
	     have written down: it never reaches this server and nothing here can
	     recover it. Choose CouchDB, enter https://${DOMAIN_HOST}, the
	     credentials from step 1, and the database name ${DB_NAME}.
	  4. Create one note, then check it arrived:
	       cd $APP_DIR && set -a && . ./.env && set +a
	       printf 'user = "%s:%s"\n' "\$COUCHDB_USER" "\$COUCHDB_PASSWORD" | curl -sS -K - https://${DOMAIN_HOST}/${DB_NAME}
	     doc_count above 0 is this install working end to end.
	  5. First backup written to $APP_DIR/backups. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight, and
	     keep the encryption passphrase alongside the CouchDB credentials,
	     because the archive is ciphertext without it.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Obsidian Sync.

  • The install is half a plugin. This page gets you the CouchDB the vault syncs through; the other half is a community plugin you install by hand inside Obsidian, on every device, and Obsidian itself is free-for-personal-use closed-source software that nothing here replaces or modifies.
  • The encryption passphrase is yours alone. Turn on end-to-end encryption and the server holds ciphertext, which is the point, and it also means the passphrase is not on the server, not in the backup, and not recoverable. Lose it and the vault in that database is noise.
  • One vault, one database, no version history UI. LiveSync replicates changes and merges simple conflicts, and CouchDB keeps the revisions underneath, but there is no dated-snapshot browser like the one Obsidian Sync sells. Restoring a note you wrecked last Tuesday is a different job here.
  • Never run two synchronisers on one vault. Upstream says so twice, and it means Obsidian Sync, iCloud, Dropbox and a folder sync tool as well. The failure is duplicated and corrupted notes, and it is not always obvious on the day it happens.
  • You own a database now. It is one container, but it is still a service to update, back up and keep a certificate pointed at, and if it is unreachable your phone quietly stops syncing rather than telling you.

Where this came from

“This plug-in is not compatible with the official "Obsidian Sync" and cannot synchronise with it.”

  • The plugin is not compatible with Obsidian Sync and cannot synchronise with it, and upstream warns against running it alongside any other synchronisation solution on the same vault. source
  • Upstream's own provisioning tool sets require_valid_user, CORS credentials, the three Obsidian origins, a 50 MB document limit and single-node setup on the CouchDB it configures, which is exactly what this install writes as a config file. source
  • The manual onboarding path enables end-to-end encryption with a vault passphrase, takes the CouchDB URL, credentials and database name, and creates that database if it does not exist. source
  • Obsidian on a mobile device requires HTTPS, and upstream says plain HTTP is suitable only for a trusted local connection from a desktop device. source
  • CouchDB's require_valid_user refuses every anonymous request, require_valid_user_except_for_up leaves /_up open as the health endpoint, and single_node creates the system databases at startup. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Obsidian Sync?

    Not Obsidian Sync itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Obsidian LiveSync. Your Obsidian vault syncs through a CouchDB you run, end-to-end encrypted, with the server storing nothing it can read. The install is one evening: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Obsidian Sync?

    Obsidian LiveSync. Your Obsidian vault syncs through a CouchDB you run, end-to-end encrypted, with the server storing nothing it can read. The only option here that is actually Obsidian sync rather than file sync underneath it. It is a community plugin that replicates your vault into a CouchDB you run, with end-to-end encryption you turn on during setup, so the server stores ciphertext and the passphrase never leaves your devices. It merges simple conflicts instead of leaving you two copies of a note, and it works on iOS and Android, which is where every folder-sync answer falls over. What you take on is a database with a certificate pointed at it, and a passphrase nobody can recover for you. Obsidian LiveSync is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Obsidian Sync?

    1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Obsidian LiveSync itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Obsidian Sync Sync Standard, $5/mo — $60 a year, 1 seat assumed.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Obsidian LiveSync install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Obsidian LiveSync on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Obsidian LiveSync on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Everything answers at http://localhost:8120, which means this computer and nowhere else, so what you get is a versioned copy of every note in a database you own rather than a phone that syncs. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.