{ config, ... }: # Plain CouchDB 3 node, tuned as the backend for Obsidian Self-hosted LiveSync # (vrtmrz/obsidian-livesync), which replicates the vault into it via PouchDB. # # Published PUBLICLY as https://notes.mgaction.town via neptun's caddy, since # Obsidian's mobile apps refuse cleartext HTTP and jupiter's *.jupiter.sol # names can't get a real cert — so the settings below are security-relevant: # - `require_valid_user` in both [chttpd] and [chttpd_auth], else CouchDB # answers unauthenticated GETs on the open internet. # - neptun's vhost allowlists only the plugin's endpoints; Fauxton and # cluster/config are reachable only over the tailnet. # - Turn on the plugin's end-to-end encryption (+ "Obfuscate Properties"), # so this server only ever holds ciphertext — what makes a # publicly-reachable credentialed database an acceptable trade. # # Its passphrase must stay a SEPARATE secret from couchdb_admin_password: # the CouchDB password is stored here and in secrets/jupiter.yaml, while # the E2EE passphrase never leaves the clients (kept in the HomeLab Proton # Pass vault, not sops) — reusing one string for both would hand the # decryption key to whoever gets the CouchDB credential. Losing the # passphrase costs the remote database, not the notes: wipe and # re-initialize from a device that still holds the plaintext vault. { services.couchdb = { enable = true; # Listens on all interfaces, but :5984 is not opened in the firewall, so # it's reachable only over tailscale0 (trusted) and localhost — 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 default under /var/lib/couchdb and must move together. databaseDir = "/mnt/data/AppData/couchdb"; viewIndexDir = "/mnt/data/AppData/couchdb"; configFile = "/mnt/data/AppData/couchdb/local.ini"; # [admins] ini fragment from sops; services.couchdb.adminPass would render # into the world-readable store instead. # # ⚠️ CouchDB hashes the password at startup and persists it to local.ini # (above), which then takes precedence — so changing the sops value alone # does NOT rotate it. Also delete the `[admins]` line from # /mnt/data/AppData/couchdb/local.ini and restart. 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" ]; }