{ 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" ]; }