Written by: Jan Bachelor
Date: 2026-08-15
I wanted to move my IT tutorials off Notion and onto something self-hosted, editable as plain Markdown, and eventually pushed straight to a self-hosted publishing site (covered in Part 2 and Part 3 of this series). Obsidian was the obvious editor, but "self-hosted Obsidian" turns out to mean two separate challenges: running the app itself somewhere reachable from a browser, and syncing a vault across a desktop, a laptop and a phone, and that without paying for Obsidian Sync or fighting a full peer-to-peer mesh across every device.
This article covers both: deploying linuxserver/obsidian (a full desktop Obsidian instance streamed to your browser via KasmVNC/Selkies) inside Docker, and syncing it against a self-hosted CouchDB instance using the self-hosted LiveSync community plugin, client-server sync, offline editing, and onboarding a new device (including Android) in one step via a Setup URI, instead of Syncthing's full-mesh pairing dance.
Neither the Obsidian container nor CouchDB need to be internet-facing. Both sit on an internal-only HAProxy frontend already used for other LAN-only services (Gitea, Termix, Semaphore UI, etc. in this environment). My phone and laptop already run WireGuard for other internal access, so it reaches these the same way when off the home network, no new public attack surface for what is, after all, a private authoring tool and its sync backend.
frontend-https-LAN-VIP in this environment)8081, every internal service gets its own server_name block on that same listener, differentiated by Host header)web2 - we set up both the Obsidian and CouchDB as separate containers:
obsidian container, bound to 127.0.0.1:3000couchdb container, bound to 127.0.0.1:5984server_name block per service on the shared 8081 listenerfrontend-https-LAN-VIP- internal-only, gets one ACL rule per serviceservices:
obsidian:
image: lscr.io/linuxserver/obsidian:latest
container_name: obsidian
restart: always
environment:
- PUID=1001
- PGID=1001
- TZ=Europe/Prague
- SELKIES_H264_CRF=18
volumes:
- ./config:/config
shm_size: "1gb"
ports:
- "127.0.0.1:3000:3000"
logging:
driver: "journald"
options:
tag: "{{.Name}}"
Two things worth explaining rather than just pasting:
shm_size: "1gb" isn't optional, this is a real Electron app running inside the container, and Chromium/Electron needs real shared memory to not crash.SELKIES_H264_CRF=18: this image doesn't actually use classic KasmVNC's protocol under the hood, it uses Selkies (WebRTC-style video streaming of the desktop). Default video quality (CRF 25) visibly blurs during scrolling/typing, that's compression, not a bug, and 18 gives a noticeably sharper picture at the cost of more CPU on the host, since encoding is done in software (no GPU passed through).Port binding is 127.0.0.1:3000:3000, never 0.0.0.0 - the internal HAProxy frontend is the only intended entry point.
Save that as compose.yml in its own directory, then bring the container up:
mkdir -p /opt/obsidian && cd /opt/obsidian
# save the compose.yml above into this directory
docker compose up -d
Confirm it's actually running before moving on:
docker ps --filter name=obsidian
server {
listen 8081;
server_name obsidian.bachelor-tech.com;
include /etc/nginx/snippets/trusted-proxies.conf;
include /etc/nginx/snippets/custom-error-403.conf;
include /etc/nginx/blocklist.conf;
location / {
proxy_pass http://127.0.0.1:3000;
# This image's desktop-stream websocket is long-lived - default
# 60s proxy timeouts will silently drop it mid-session.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Save that to /etc/nginx/sites-available/obsidian.conf (adjust the path to whatever convention your own nginx install uses), symlink it into sites-enabled/, then test and reload:
sudo ln -s /etc/nginx/sites-available/obsidian.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
nginx -t matters here specifically: reloading straight away on a typo takes the whole shared 8081 listener down for every other vhost on it too, not just this one.
On the internal frontend-https-LAN-VIP:
web2_nginx, port 8081). Nginx does the host-based routing, HAProxy doesn't need a new server entry per service.3600s, and under Advanced → Pass Thru add timeout tunnel 3600s. OPNsense's GUI doesn't expose this setting directly, and without it the desktop-stream websocket gets killed by the default timeout server.hdr - HTTP Host Header matches → obsidian.bachelor-tech.comfrontend-https-LAN-VIPobsidian.bachelor-tech.com at the LAN-VIP addressThe shared ACME certificate for bachelor-tech.com on this frontend is issued with explicit domain names, not a wildcard. In OPNSense, go to Services -> ACME and ensure that you have added your subdomain (such as obsidian.bachelor-tech.com) into the list.
A brand-new subdomain silently fails HTTPS (the browser downgrades to plain HTTP, showing "Not secure" with the https struck through in the address bar) until you add the new hostname to the certificate's domain list and force a reissue. The same applies to CouchDB's subdomain later (HTTPS is required for the sync).
Once https://obsidian.bachelor-tech.com loads, create a local vault as normal.
📷 Screenshot: the "Create local vault" screen on first load
Two follow-ups worth knowing before you start writing:
/config) is the only persisted path. Put the vault inside it (e.g. /config/vault/), anywhere outside that mount doesn't survive a container restart.services:
couchdb:
image: couchdb:3.4
container_name: couchdb
restart: always
user: "5984:5984"
environment:
- COUCHDB_USER=obsidian
- COUCHDB_PASSWORD=<generate a strong password>
volumes:
- ./data:/opt/couchdb/data
- ./etc/local.d:/opt/couchdb/etc/local.d
ports:
- "127.0.0.1:5984:5984"
logging:
driver: "journald"
options:
tag: "{{.Name}}"
Save that as compose.yml in its own directory, then set ownership on the data/config directories before starting the container, CouchDB's data/config directories need to be owned by UID 5984, or it fails to start at all:
mkdir -p /opt/couchdb/data /opt/couchdb/etc/local.d && cd /opt/couchdb
# save the compose.yml above into this directory
chown -R 5984:5984 ./data ./etc
docker compose up -d
Confirm it's actually running before moving on:
docker ps --filter name=couchdb
COUCHDB_USER/COUCHDB_PASSWORD alone doesn't create the system databases, check _all_dbs, and if it's empty, create them:
AUTH="obsidian:<password>"
BASE="http://127.0.0.1:5984"
for db in _users _replicator _global_changes; do
curl -s -u "$AUTH" -X PUT "$BASE/$db"
done
Self-hosted LiveSync needs CORS enabled with two specific non-http origins, app://obsidian.md for desktop, capacitor://localhost for Android/iOS:
curl -s -u "$AUTH" -X PUT "$BASE/_node/_local/_config/httpd/enable_cors" -d '"true"'
curl -s -u "$AUTH" -X PUT "$BASE/_node/_local/_config/chttpd/enable_cors" -d '"true"'
curl -s -u "$AUTH" -X PUT "$BASE/_node/_local/_config/cors/origins" -d '"app://obsidian.md,capacitor://localhost,http://localhost"'
curl -s -u "$AUTH" -X PUT "$BASE/_node/_local/_config/cors/credentials" -d '"true"'
curl -s -u "$AUTH" -X PUT "$BASE/_node/_local/_config/cors/methods" -d '"GET,PUT,POST,HEAD,DELETE"'
curl -s -u "$AUTH" -X PUT "$BASE/_node/_local/_config/cors/headers" -d '"accept, authorization, content-type, origin, referer"'
And tuning for embedded images in notes (default document/request size limits are too small):
curl -s -u "$AUTH" -X PUT "$BASE/_node/_local/_config/couchdb/max_document_size" -d '"50000000"'
curl -s -u "$AUTH" -X PUT "$BASE/_node/_local/_config/chttpd/max_http_request_size" -d '"4294967296"'
curl -s -u "$AUTH" -X PUT "$BASE/_node/_local/_config/chttpd_auth/require_valid_user" -d '"true"'
curl -s -u "$AUTH" -X PUT "$BASE/_node/_local/_config/httpd/WWW-Authenticate" -d '"Basic realm=couchdb"'
Create the database that the vault will sync into:
curl -s -u "$AUTH" -X PUT "$BASE/obsidian-vault"
Same pattern as Step 2/3, one difference: proxy_buffering off matters here specifically, since CouchDB's _changes feed (what LiveSync uses for near-real-time updates) is a long-lived streaming response, buffering it delays updates reaching clients.
server {
listen 8081;
server_name couchdb.bachelor-tech.com;
include /etc/nginx/snippets/trusted-proxies.conf;
include /etc/nginx/snippets/custom-error-403.conf;
include /etc/nginx/blocklist.conf;
client_max_body_size 50M;
proxy_buffering off;
location / {
proxy_pass http://127.0.0.1:5984;
proxy_http_version 1.1;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Same as Step 2: save it to /etc/nginx/sites-available/couchdb.conf, symlink it into sites-enabled/, then test and reload:
sudo ln -s /etc/nginx/sites-available/couchdb.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
HAProxy side: identical shape to Step 3 (reuse web2_nginx, timeout tunnel 3600s, condition on couchdb.bachelor-tech.com, add to frontend-https-LAN-VIP). Remember the certificate SAN list again here, same gotcha, second occurrence.
For the phone to resolve couchdb.bachelor-tech.com while connected over WireGuard away from home, the tunnel's peer config needs to push the internal DNS resolver, or the VPN comes up fine but the hostname simply won't resolve:
DNS = 192.168.8.254
Use your own internal resolver's address based on your home lab's setting
https://couchdb.bachelor-tech.com, Username obsidian, Password (from Step 6), Database obsidian-vault📷 Screenshot: the "CouchDB Configuration" form with the URL/username/database fields filled in (password blurred)
0/60 tuned for constrained backends), accept the fix. This is genuinely better for a self-hosted CouchDB with the larger max_document_size we set above, and it's much cleaner to change before real content exists than after.📷 Screenshot: the "Data retrieval scheduled" merge-strategy screen
📷 Screenshot: the "Conflict & Deletion Options" screen (how to handle files deleted on other devices)
If the container's browser session goes fully black and a page refresh doesn't fix it, check whether the Obsidian process itself is still running inside the container before assuming the video stream is broken:
docker exec obsidian ps aux | grep -i obsidian
If nothing's there, the app was closed (e.g. navigating back to the vault picker can sometimes fully quit it) and needs relaunching, docker restart obsidian is the reliable fix, since the container's init sequence relaunches the app fresh.
"Fetch everything operation completed" in the sync log is describing a pull (remote → local), not a push. If you interrupt an initial "upload this device as master" operation (e.g. by closing the vault mid-sync, or restarting the app before a queued background push finishes), reconnecting can silently re-run as a plain fetch-and-join instead of resuming the original push, leaving the remote database still empty despite looking like everything succeeded. Check the actual document count on the server (GET /obsidian-vault → doc_count) if anything about the sync timing felt interrupted, rather than trusting the toast alone.
📷 Screenshot: the Setup URI / QR code screen on desktop, and the same vault open on Android after scanning it
Generate the Setup URI after confirming the desktop push actually landed real documents on the server (doc_count > 1, not just the internal version-marker document), otherwise the phone just inherits the same not-yet-synced state.
LiveSync (above) solves availability: every device sees the same vault, close to real time. It doesn't give you history. CouchDB keeps its own internal revision log, but that's not the same thing as a real, browsable git history you can diff, roll back to a specific point, or keep as a plain off-site copy outside the CouchDB database itself.
So a second, independent job covers that: every 20 minutes, web2 commits and pushes the entire vault (not just a curated subfolder) straight to its own Gitea repository, obsidian_vault_backup. It runs against the server-side copy at /opt/obsidian/config/vault/, the same folder the browser-accessed container in Step 1 reads and writes, which LiveSync keeps in sync with the desktop, so this backup effectively captures whatever the desktop looks like too, just on a 20-minute delay rather than instantly.
Authentication is the same pattern used everywhere else in this series: a git SSH deploy key scoped to that one repository alone (obsidian_vault_backup, write access, since this job needs to push), not a broad account-wide token.
#!/bin/bash
# /opt/vault-backup/backup_vault.sh
set -e
cd "/opt/obsidian/config/vault/Jan's Tutorials"
git add -A
if ! git diff --cached --quiet; then
git commit -q -m "vault backup: $(date -Iseconds)"
git push -q
echo "backed up: $(date -Iseconds)"
else
echo "no changes: $(date -Iseconds)"
fi
# /etc/systemd/system/vault-backup.service
[Unit]
Description=Back up the full Obsidian vault to Gitea
After=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/vault-backup/backup_vault.sh
# /etc/systemd/system/vault-backup.timer
[Unit]
Description=Run vault-backup.service every 20 minutes
[Timer]
OnBootSec=3min
OnUnitActiveSec=20min
AccuracySec=1min
[Install]
WantedBy=timers.target
Save the script to /opt/vault-backup/backup_vault.sh and make it executable, save the two unit files to /etc/systemd/system/, then enable and start the timer:
sudo mkdir -p /opt/vault-backup
sudo chmod +x /opt/vault-backup/backup_vault.sh
sudo systemctl daemon-reload
sudo systemctl enable --now vault-backup.timer
Confirm it's actually scheduled, and that a run went cleanly, before trusting it:
systemctl list-timers vault-backup.timer
sudo systemctl start vault-backup.service # trigger one run immediately, rather than waiting up to 20 minutes
journalctl -u vault-backup.service --no-pager -n 10
Deliberately plain: no image processing, no filename conventions, no rewriting anything, just a straight git add -A of the entire vault on a timer. That's the right level of ambition for a safety-net backup covering everything you've ever written, including drafts, private notes, and anything not meant to go anywhere near a public site.
The vault's Published/ folder is the one exception: it's curated content meant for public consumption, and it gets a second, much more involved pipeline on top of this one, image resizing, SEO-friendly renaming, and an automatic publish to a live Grav CMS site. That pipeline is entirely separate from this backup (different repository, different host, different trigger), and is covered in full in Part 3.