Featured image of post Self-hosting Obsidian Livesync

Self-hosting Obsidian Livesync

Note-taking app has now become a central piece of my daily life and developer workflow. I started off with Evernote, then migrated everything to Obsidian, drawn to the idea that everything was just a bunch of plain files: all my notes are stored as markdown files, and links and attachments being merely wikilinks pointing to other files.

As I got into the rabbithole of self-hosting, it occurred to me that I could self-host these notes as well.

It’s not that I have anything against the official Obsidian Sync service - in fact I loved it for the seamless syncing experience, and the monthly cost of $4 seemed very reasonable to me. I just couldn’t help but think it was “lame” having a homelab and not self-hosting my own notes, and it would be “cool” to have a self-hosted setup that works as seamlessly as the official solution.

These were the requirements I had in mind:

  • Self-hosted sync
  • Works on mobile, Windows, and Linux
  • Fairly mature and popular (not someone’s experimental project)
  • Does not require manual syncing

I explored several options, and finally settled on Obsidian LiveSync.

Obsidian Remotely Save plugin with NextCloud WebDAV

The first solution I looked into was the Remotely Save community plugin.

I am already running a NextCloud instance on my home lab kubernetes cluster, and NextCloud’s WebDAV was explicitly listed as a supported backend for Remotely Save. It seemed like a natural fit for my existing setup.

All I had to do was set up an app password on NextCloud side: NextCloud app password

create a folder in NextCloud to store the notes, and then configure the plugin to point to the WebDAV URL like the following, with the app password.

1
https://<nextcloud-host>/remote.php/dav/files/jy/<folder_path>

It worked, but…

  • Self-hosted sync
  • Works on mobile, Windows, and Linux
  • Fairly mature and popular (not someone’s experimental project)
  • Does not require manual syncing

sadly did not work on my phone. It worked flawlessly on my Linux and Windows computers, but nothing was being pushed or pulled on mobile.

So I had to move on.

Joplin

At this point, I thought maybe I should look into some other apps which has first-party self-hosted syncing support. Afterall, Obsidian’s official sync service is not self-hosted, and surely there would be less friction if the app itself supported self-hosted syncing.

Joplin looked very promising. It was mature, had a large community, and had an official container image for the sync backend.

So I decided to give it a shot by hosting it on my kubernetes cluster.

joplin.yml
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
apiVersion: apps/v1
kind: Deployment
metadata:
  name: joplin
  namespace: joplin
  labels:
    app: joplin
  annotations:
    reloader.stakater.com/auto: "true"
spec:
  replicas: 1
  selector:
    matchLabels:
      app: joplin
  template:
    metadata:
      labels:
        app: joplin
    spec:
      containers:
      - name: joplin
        image: joplin/server:3.7.2
        ports:
        - containerPort: 22300
        env:
          - name: APP_PORT
            value: "22300"
          - name: APP_BASE_URL
            value: https://joplin.junyi.me
          - name: DB_CLIENT
            value: pg
          - name: POSTGRES_HOST
            value: central-rw.postgres.svc.cluster.local
          - name: POSTGRES_PORT
            value: "5432"
          - name: POSTGRES_DATABASE
            valueFrom:
              secretKeyRef:
                name: joplin-db-credentials
                key: db
          - name: POSTGRES_USER
            valueFrom:
              secretKeyRef:
                name: joplin-db-credentials
                key: username
          - name: POSTGRES_PASSWORD
            valueFrom:
              secretKeyRef:
                name: joplin-db-credentials
                key: password
          - name: S3_ACCESS_KEY
            valueFrom:
              secretKeyRef:
                name: joplin-object
                key: access_key
          - name: S3_SECRET_KEY
            valueFrom:
              secretKeyRef:
                name: joplin-object
                key: secret_key
          # joplin doesn't set forcePathStyle; a dotted bucket name over https makes the
          # aws sdk fall back to path-style, since rgw has no wildcard dns for vhost-style
          - name: STORAGE_DRIVER
            value: "Type=S3; Region=default; Endpoint=https://rgw.i.junyi.me; Bucket=joplin.notes; AccessKeyId=$(S3_ACCESS_KEY); SecretAccessKeyId=$(S3_SECRET_KEY)"
          - name: MAILER_ENABLED
            value: "true"
          - name: MAILER_HOST
            value: stalwart.mail.svc.cluster.local
          - name: MAILER_PORT
            value: "2525"
          - name: MAILER_SECURITY
            value: none
          - name: MAILER_AUTH_USER
            valueFrom:
              secretKeyRef:
                name: joplin-smtp
                key: username
          - name: MAILER_AUTH_PASSWORD
            valueFrom:
              secretKeyRef:
                name: joplin-smtp
                key: password
          - name: MAILER_NOREPLY_NAME
            value: Joplin
          - name: MAILER_NOREPLY_EMAIL
            valueFrom:
              secretKeyRef:
                name: joplin-smtp
                key: from

---

apiVersion: v1
kind: Service
metadata:
  name: joplin
  namespace: joplin
spec:
  selector:
    app: joplin
  ports:
  - protocol: TCP
    targetPort: 22300
    port: 80
expose.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: junyi-me-prod
  namespace: joplin
spec:
  secretName: junyi-me-prod
  issuerRef:
    name: letsencrypt-production
    kind: ClusterIssuer
  dnsNames:
  - "joplin.junyi.me"

---

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: joplin
  namespace: joplin
spec:
  ingressClassName: traefik
  rules:
    - host: joplin.junyi.me
      http:
        paths:
        - path: /
          pathType: Prefix
          backend:
            service:
              name: joplin
              port:
                number: 80
  tls:
    - hosts:
        - "joplin.junyi.me"
      secretName: junyi-me-prod
secret.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: joplin-db-credentials
  namespace: joplin
spec:
  secretStoreRef:
    name: vault
    kind: ClusterSecretStore
  target:
    name: joplin-db-credentials
  dataFrom:
    - extract:
        key: secret/joplin/db

---

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: joplin-object
  namespace: joplin
spec:
  secretStoreRef:
    name: vault
    kind: ClusterSecretStore
  target:
    name: joplin-object
  dataFrom:
    - extract:
        key: secret/joplin/object

---

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: joplin-smtp
  namespace: joplin
spec:
  secretStoreRef:
    name: vault
    kind: ClusterSecretStore
  target:
    name: joplin-smtp
  dataFrom:
    - extract:
        key: secret/joplin/smtp
Secrets
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
S3_ACCESS_KEY=[redacted]
S3_SECRET_KEY=[redacted]
JOPLIN_DB_PW=[redacted]
PG_ADMIN_USER=[redacted]
PG_ADMIN_PW=[redacted]
JOPLIN_MAIL_PW=[redacted]

# with port forward
vault login [token]

vault kv put secret/joplin/db username=joplin password="$JOPLIN_DB_PW" db=joplin
vault kv put secret/joplin/object access_key="$S3_ACCESS_KEY" secret_key="$S3_SECRET_KEY"
vault kv put secret/joplin/smtp username=joplin password="$JOPLIN_MAIL_PW" from=joplin@junyi.me

# confirm
vault kv get secret/joplin/db && vault kv get secret/joplin/object
vault kv get secret/joplin/smtp
Postgres
1
2
3
4
5
6
7
8
# with port forward
PGPASSWORD="$PG_ADMIN_PW" psql -h localhost -U "$PG_ADMIN_USER" -d postgres <<SQL
CREATE ROLE joplin LOGIN PASSWORD '$JOPLIN_DB_PW';
CREATE DATABASE joplin OWNER joplin;
SQL

# confirm
PGPASSWORD="$JOPLIN_DB_PW" psql -h 10.0.69.234 -U joplin -d joplin -c '\dt'

Once the pod was running, the client-side config was very straightforward. Just enter the server URL and credentials, and joplin started to sync right away. Joplin sync config

I tested syncing between two computers and a phone, and everything was going smoothly. Although I set the “Synchronization interval” to 5 minutes, the sync felt instantaneous after each edit. So,

  • Self-hosted sync
  • Works on mobile, Windows, and Linux
  • Fairly mature and popular (not someone’s experimental project)
  • Does not require manual syncing

Finally something that ticks all those boxes! However, since this would be an app switch, there were certainly some trade-offs.

  1. There was no way to sync settings and plugins.
  2. Keybindings can be exported and imported, but the process feels tedious especially when setting things up.
  3. Linking felt a bit clunky - no way to set up keybindings to create a link and follow a link.
  4. vim support is built-in, which is great, but some keybindings only work in insert mode

I tried joplin for a while, but as someone who uses vim keybindings and want to use keyboard as much as possible, issues 3 and 4 were bothering me too much.

I considered looking into some plugins to improve the experience, but issue 1 made me decide looking into some other options first. Setting up plugins and configs once is okay, but setting them up on every device seemed like a hustle.

Other note-taking apps

Logseq

This is an interesting one. I’ve seen a lot of positive feedback about its approach to note-taking.

As I understand, Logseq organizes everything by blocks instead of notes. The blocks can be nested inside of other blocks, can individually become tasks and be tagged, and individually referenced by other blocks.

The concept sounded efficient, and it got me interested to learn more about it.

Howerver, the more I learn about it, the more it seems that the syncing functionality is limited, and there doesn’t seem to be a lot of active development going on.

  • Self-hosted sync
  • Works on mobile, Windows, and Linux
  • Fairly mature and popular (not someone’s experimental project)
  • Does not require manual syncing

Trilium Notes

This is another pretty popular option for note taking, with built-in support for self-hosted syncing.

The lack of mobile app and the markdown editor were what kept me from trying it out.

  • Self-hosted sync
  • Works on mobile, Windows, and Linux
  • Fairly mature and popular (not someone’s experimental project)
  • Does not require manual syncing

Obsidian LiveSync with CouchDB

Having looked into some other options, I still liked the note-taking experience of Obsidian the best. I decided to explore some other Obsidian plugins.

Obsidian Livesync was the obvious choice here, and I ended up sticking to it. It is another Obsidian community plugin which uses CouchDB as its backend.

I had previously avoided this because the CouchDB setup process looked a bit tedious, but having seen the overwhelmingly positive reviews on Reddit, I decided to give it a try.

For the backend, a standard CouchDB instance was sufficient. I used ArgoCD to deploy the official CouchDB helm chart, following the Obsidian Livesync setup guide.

Manifests and secrets

obsidian-couch.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  annotations:
    notifications.argoproj.io/subscribe.mattermost: <redacted>
  name: obsidian-couch
  namespace: argocd
spec:
  destination:
    namespace: obsidian
    server: https://kubernetes.default.svc
  project: default
  source:
    repoURL: https://apache.github.io/couchdb-helm
    chart: couchdb
    targetRevision: 4.6.3
    helm:
      valuesObject:
        clusterSize: 1

        couchdbConfig:
          couchdb:
            uuid: <redacted>
            single_node: "true"
            max_document_size: "50000000"
          cluster:
            "n": 1
          chttpd:
            bind_address: any
            require_valid_user: true
            enable_cors: "true"
            max_http_request_size: "4294967296"
          chttpd_auth:
            require_valid_user: "true"
          httpd:
            WWW-Authenticate: Basic realm="couchdb"
            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

        createAdminSecret: false
        extraSecretName: admin
        adminUsernameKey: username
        adminPasswordKey: password
        cookieAuthSecretKey: cookieAuthSecret
        erlangCookieKey: erlangCookie

        persistentVolume:
          enabled: true
          storageClass: csi-rbd-sc
          size: 30Gi

        ingress:
          enabled: true
          ingressClassName: traefik
          hosts:
            - obsidian-couch.junyi.me
          tls:
            - secretName: junyi-me-production
              hosts:
                - obsidian-couch.junyi.me
  syncPolicy:
    automated:
      enabled: true
      prune: true
      selfHeal: true
    syncOptions:
    - CreateNamespace=true
secret.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: admin
  namespace: obsidian
spec:
  secretStoreRef:
    name: vault
    kind: ClusterSecretStore
  target:
    name: admin
  dataFrom:
    - extract:
        key: secret/obsidian/couch
Secrets
1
2
3
4
5
6
7
8
ADMIN_USER=[redacted]
ADMIN_PW=$(openssl rand -base64 24)

vault kv put secret/obsidian/couch \
    username=$ADMIN_USER \
    password="$ADMIN_PW" \
    cookieAuthSecret="$(openssl rand -hex 32)" \
    erlangCookie="$(openssl rand -hex 32)"

Per-user DB setup

This was the part that felt a bit tedious for me. For each new Obsidian vault, a new CouchDB database has to be created. For now it’s alright for me since I’m the only user using it and I usually put everything into a single vault.

DB setup
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
COUCH_URL=https://obsidian-couch.junyi.me
SYNC_USER=[redacted]
SYNC_PASS="$(openssl rand -hex 16)"
SYNC_DB=[redacted]
E2EE_PASS=[redacted]

# 1. Create the DB and LiveSync version doc (init script needs admin creds)
hostname=$COUCH_URL username=$ADMIN_USER password=$ADMIN_PW database=$SYNC_DB \
    bash -c 'curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash'

# 2. Create a non-admin user
curl -u "$ADMIN_USER:$ADMIN_PW" -X PUT "$COUCH_URL/_users/org.couchdb.user:$SYNC_USER" \
    -H 'Content-Type: application/json' \
    -d "{\"name\":\"$SYNC_USER\",\"password\":\"$SYNC_PASS\",\"roles\":[],\"type\":\"user\"}"

# 3. Restrict the DB to that user
curl -u "$ADMIN_USER:$ADMIN_PW" -X PUT "$COUCH_URL/$SYNC_DB/_security" \
    -H 'Content-Type: application/json' \
    -d "{\"admins\":{\"names\":[],\"roles\":[]},\"members\":{\"names\":[\"$SYNC_USER\"],\"roles\":[]}}"

# 4. Generate the setup URI with the user's own credentials
hostname=$COUCH_URL database=$SYNC_DB username=$SYNC_USER password=$SYNC_PASS passphrase=$E2EE_PASS \
    deno run --minimum-dependency-age=0 --allow-env \
    https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts

The last command spit out the following output, which was needed in the client setup.

1
2
3
4
5
6
7
Generated couchdb Setup URI.
Ephemeral: usable until 2026-10-15T00:00:00.000Z (UTC).
Your passphrase for the Setup URI is: [redacted]
This passphrase is never shown again, so store it safely.
ID recovery code: [redacted]
Use id_recovery_code with this value and reuse the same remote settings when generating another Setup URI for the same Vault.
obsidian://setuplivesync?settings=[redacted]

Client setup

warning

Before setting this up, make sure the vault is backed up by other means. I used Obsidian Sync.
Once everything is backed up, turn OFF any automatic backup functionalities other than Obsidian Livesync.

When setting up the first client, the client has to sync all the local files to the CouchDB database. This was done in the Self-hosted Livesync plugin settings in Obsidian.

First client sttings

The documentation on their github was pretty easy to follow.

For the other devices, the process was similar: just had paste in the same URL and follow the prompts (also in documentation).

Test run

After setting up the plugin, the plugin immediately started syncing to CouchDB. The option in command palette “Self-hosted LiveSync: Show Log” was very handy to see what’s going on.

The first sync took me about 20 minutes, with the vault taking up 3GB or so. After that an overlay was displayed on top-right corner, and it started to react to any changes and show syncing status. Sync status indicator

The last thing I tweaked was the synchronization preset. I set it to “LiveSync” for the most real-time experience possible. Sync preset

Then I tested

  1. Editing on Linux desktop, viewing on mobile
  2. Editing on mobile, viewing on Linux desktop
  3. Same thing, but with note and attachment creation
  4. Deleting files

All worked flawlessly.

So far so good

I have been using this setup for a week now, with various devices and connection conditions, and so far, it’s doing a great job.

Devices: Linux desktop and laptop, windows laptop, and Android phone.
Locations: home, public and private wifi away from home, and on the plane (offline, successfully synced right after landing).

Sometimes right after adding a new device, it refuses to push changes to server (pull works), but as per my experience, the issue goes away after a restart of the app. Other than that, it’s been very reliable.

Built with Hugo
Theme Stack designed by Jimmy