obsidian: self-hosted vault sync via CouchDB on jupiter

Adds services/dev/obsidian-livesync.nix — CouchDB 3 from the native
nixpkgs module, tuned as the backend for the Self-hosted LiveSync plugin
— and publishes it as notes.mgaction.town through neptun.

It goes out over the public reverse proxy rather than staying on the LAN
because Obsidian's mobile apps refuse cleartext HTTP and *.jupiter.sol
cannot hold a publicly trusted cert. That makes the hardening load-bearing
rather than decorative:

  - require_valid_user in both [chttpd] and [chttpd_auth], so nothing
    answers unauthenticated on the open internet;
  - neptun's vhost matches on CouchDB's own naming rule (system endpoints
    all begin with `_`, user databases never can), so Fauxton, /_all_dbs
    and /_node/_local/_config — which rewrites the server config given
    admin credentials — 404 at the proxy while any number of per-vault
    databases pass. Verified against both sets of paths with caddy run
    against a stub backend;
  - the plugin's own E2EE carries the actual confidentiality: jupiter only
    ever stores ciphertext. Its passphrase is deliberately NOT in sops —
    it never leaves the clients, and pairing it with the server credential
    would defeat the point.

flush_interval -1 is required, not tuning: replication rides a continuous
_changes feed that caddy would otherwise buffer into a stall.

Storage sits on the array with RequiresMountsFor, since a CouchDB that
starts without /mnt/data would create an empty database on the eMMC and
LiveSync would replicate that emptiness back to every client. Logs go to
journald rather than the unrotated /var/log/couchdb.log, for the same
29G-eMMC reasons as the rest of jupiter.

The admin password reaches CouchDB as an [admins] ini fragment via
extraConfigFiles; services.couchdb.adminPass would have rendered it into
the world-readable store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLN5nkLBtCciD3ZnUwtw2b
This commit is contained in:
2026-08-25 23:25:37 +02:00
co-authored by Claude Opus 5
parent 94061bd80a
commit 15ae1cf608
5 changed files with 205 additions and 2 deletions
+128
View File
@@ -0,0 +1,128 @@
{ config, ... }:
# CouchDB, tuned as the backend for Obsidian Self-hosted LiveSync
# (vrtmrz/obsidian-livesync). The plugin replicates the vault into CouchDB
# chunk-by-chunk over PouchDB's replication protocol, so this is a plain
# CouchDB 3 node — nothing Obsidian-specific runs here.
#
# Published PUBLICLY as https://notes.mgaction.town via neptun's caddy (see
# hosts/neptun/configuration.nix), because Obsidian's mobile apps refuse
# cleartext HTTP and jupiter's *.jupiter.sol names cannot get a real cert.
# That makes the settings below security-relevant, not cosmetic:
#
# - `require_valid_user` in BOTH [chttpd] and [chttpd_auth]: without it
# CouchDB answers unauthenticated GETs on the open internet.
# - neptun's vhost allowlists only the endpoints the plugin uses, so Fauxton
# (/_utils) and the cluster/config endpoints are not reachable from
# outside at all — reach them over the tailnet instead.
# - Turn ON end-to-end encryption in the plugin (Settings → Remote Database
# → End-to-End Encryption, plus "Obfuscate Properties", which covers the
# paths and timestamps that E2EE alone leaves readable). Then this server
# only ever holds ciphertext, which is what makes a publicly-reachable
# credentialed database an acceptable trade rather than a bad one.
#
# Its passphrase is a SEPARATE secret from couchdb_admin_password below —
# deliberately, and it must stay that way. The couchdb password
# authenticates to this server and is stored here (hashed) and in
# secrets/jupiter.yaml; the E2EE passphrase never leaves the Obsidian
# clients and CouchDB has no idea it exists. Reusing one string for both
# hands whoever obtains that credential the decryption key as well, which
# is precisely the failure E2EE is here to prevent. The passphrase is
# therefore NOT in sops (nothing on this host consumes it) — it lives in
# the HomeLab Proton Pass vault, with the deploy credentials.
#
# Losing it costs the remote database, not the notes: wipe it and
# re-initialize from a device that still holds the plaintext vault.
{
services.couchdb = {
enable = true;
# Listens on all interfaces, same reasoning as immich: :5984 is NOT opened
# in the firewall, so it is reachable over tailscale0 (trusted in
# common.nix) and localhost only. That is the path neptun's caddy takes.
bindAddress = "0.0.0.0";
port = 5984;
# The vault database is the ONLY copy of the notes once LiveSync is the
# source of truth, so it belongs on the array, not the 29G eMMC. All three
# of these default under /var/lib/couchdb and have to move together —
# configFile especially, since CouchDB writes to it at runtime (below).
databaseDir = "/mnt/data/AppData/couchdb";
viewIndexDir = "/mnt/data/AppData/couchdb";
configFile = "/mnt/data/AppData/couchdb/local.ini";
# The admin password, as an [admins] ini fragment from sops.
# services.couchdb.adminPass would render it into the world-readable
# store; extraConfigFiles is the module's own documented hook for this
# (hosts/jupiter/secrets.nix renders the template).
#
# ⚠️ CouchDB hashes a plaintext admin password at startup and persists the
# hash to the LAST, writable file in its ini chain — local.ini above,
# which then takes precedence over this fragment. So changing the sops
# value alone does NOT rotate the password: delete the `[admins]` line
# from /mnt/data/AppData/couchdb/local.ini and restart as well.
extraConfigFiles = [ config.sops.templates."couchdb-admins.ini".path ];
# Values taken from LiveSync's own CouchDB setup documentation; the plugin
# refuses to replicate (or silently truncates) without them.
extraConfig = {
couchdb = {
# Creates _users/_replicator on first boot instead of leaving the node
# in the un-set-up state where every request 500s.
single_node = "true";
# LiveSync splits notes into chunks, but a big pasted image still
# arrives as one document. 8MB (the default) is too small.
max_document_size = "50000000";
};
chttpd = {
require_valid_user = "true";
max_http_request_size = "4294967296";
enable_cors = "true";
};
chttpd_auth = {
require_valid_user = "true";
authentication_redirect = "/_utils/session.html";
};
httpd = {
# Makes CouchDB answer 401 with a WWW-Authenticate challenge rather
# than a bare 401 body — the plugin's basic-auth flow depends on it.
"WWW-Authenticate" = ''Basic realm="couchdb"'';
enable_cors = "true";
};
# Obsidian is an Electron/Capacitor app, so its requests carry these
# non-http origins. Without them desktop and mobile both fail CORS
# preflight and the plugin reports a bare "cannot connect".
cors = {
credentials = "true";
origins = "app://obsidian.md,capacitor://localhost,http://localhost";
headers = "accept, authorization, content-type, origin, referer";
methods = "GET, PUT, POST, HEAD, DELETE";
max_age = "3600";
};
# The module points [log] file at /var/log/couchdb.log, which nothing
# rotates — on a 29G eMMC an info-level log of every replication request
# is a slow disk-fill. stderr hands it to journald's capped storage
# instead (the file setting is then ignored).
log = {
writer = "stderr";
level = "warning";
};
};
};
# /mnt/data/AppData is drwx--x--- darman:users, so the couchdb user needs
# group "users" just to traverse into its own database dir. The dir itself
# is created couchdb:couchdb by the module's tmpfiles rule.
users.users.couchdb.extraGroups = [ "users" ];
# databaseDir is outside /var/lib, so systemd derives no mount dependency
# from it. Without this CouchDB starts with the array missing, creates an
# empty database on the eMMC, and LiveSync sees a remote vault that lost
# every note — which it would then happily replicate back to the clients.
systemd.services.couchdb.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
}