{ pkgs, lib, config, ... }: let enabled = config.mod.restic.enable; # Where the consistent, restic-ready copies of service state are staged. # Lives on the external backup disk. root-only (0700): these dumps contain # credentials (HA .storage, secrets.yaml, session tokens, the romm database). stateDir = "/mnt/backup/public/state"; # Produce consistent, restore-ready copies of every service's state under # ${stateDir}. Runs as root before the state backups. SQLite DBs are copied # via the online-backup API (safe on a live, WAL-mode DB); the romm MariaDB is # dumped logically with mariadb-dump --single-transaction. Rebuildable caches # and indexes are skipped to keep the offsite copy lean. stateDumpScript = pkgs.writeShellScript "state-backup-dump" '' export PATH=${ lib.makeBinPath [ pkgs.sqlite pkgs.rsync pkgs.podman pkgs.coreutils pkgs.findutils ] } set -euo pipefail umask 077 STATE=${lib.escapeShellArg stateDir} install -d -m700 "$STATE" # snapshot SRC DEST [extra rsync excludes...] # Copies plain files with rsync (excluding live DBs, their WAL/SHM sidecars # and logs), then makes a consistent online copy of each *.db / *.sqlite. snapshot() { local src="$1" dst="$2" shift 2 if [ ! -e "$src" ]; then echo "state-backup: skip (missing) $src" return 0 fi install -d -m700 "$dst" rsync -a --delete \ --exclude='*.sqlite' --exclude='*.sqlite-shm' --exclude='*.sqlite-wal' \ --exclude='*.db' --exclude='*.db-shm' --exclude='*.db-wal' \ --exclude='*.log' --exclude='*.log.*' \ --exclude='__pycache__/' \ "$@" \ "$src/" "$dst/" while IFS= read -r db; do local rel="''${db#"$src"/}" install -d -m700 "$dst/$(dirname "$rel")" sqlite3 "$db" ".backup '$dst/$rel'" done < <(find "$src" -maxdepth 4 -type f \( -name '*.sqlite' -o -name '*.db' \)) } # --- romm: logical MariaDB dump + user assets (saves/states/screenshots) --- # Cover art under resources/ is intentionally skipped (refetched from # metadata providers); redis is a rebuildable cache. if podman exec romm-db true 2>/dev/null; then install -d -m700 "$STATE/romm" podman exec romm-db sh -c \ 'exec mariadb-dump --user="$MARIADB_USER" --password="$MARIADB_PASSWORD" --single-transaction --no-tablespaces "$MARIADB_DATABASE"' \ > "$STATE/romm/romm.sql" rsync -a --delete /var/lib/romm/assets/ "$STATE/romm/assets/" else echo "state-backup: skip (romm-db not running)" fi # --- komga: only database.sqlite matters (read progress, users, collections). # tasks.sqlite is transient and the lucene/ index is rebuilt on demand. --- if [ -e /var/lib/komga/database.sqlite ]; then install -d -m700 "$STATE/komga" sqlite3 /var/lib/komga/database.sqlite ".backup '$STATE/komga/database.sqlite'" fi # --- home-assistant: config, credentials and integrations, plus the two live # SQLite DBs. Recorder history (home-assistant_v2.db) is included per choice. # deps/ and tts/ are reinstalled/regenerated; logs are dropped. --- HA=/home/alex/.config/home-assistant if [ -e "$HA" ]; then install -d -m700 "$STATE/home-assistant" rsync -a --delete \ --exclude='home-assistant_v2.db' --exclude='home-assistant_v2.db-shm' --exclude='home-assistant_v2.db-wal' \ --exclude='zigbee.db' --exclude='zigbee.db-shm' --exclude='zigbee.db-wal' \ --exclude='*.log' --exclude='*.log.*' --exclude='*.log.fault' \ --exclude='deps/' --exclude='tts/' --exclude='__pycache__/' \ "$HA/" "$STATE/home-assistant/" for db in home-assistant_v2.db zigbee.db; do if [ -e "$HA/$db" ]; then sqlite3 "$HA/$db" ".backup '$STATE/home-assistant/$db'" fi done fi # --- remaining NixOS services: generic snapshot, skipping rebuildable caches --- snapshot /var/lib/navidrome "$STATE/navidrome" --exclude='cache/' snapshot /var/lib/jellyfin "$STATE/jellyfin" --exclude='transcodes/' --exclude='cache/' --exclude='metadata/' --exclude='log/' --exclude='data/subtitles/' snapshot /var/lib/audiobookshelf "$STATE/audiobookshelf" --exclude='metadata/' snapshot /var/lib/puppy-tracker "$STATE/puppy-tracker" snapshot /var/lib/solo-referee "$STATE/solo-referee" snapshot /var/lib/todo "$STATE/todo" snapshot /var/lib/komga-book-manager "$STATE/komga-book-manager" snapshot /var/lib/komga-reading-stats "$STATE/komga-reading-stats" echo "state-backup: dump complete" ''; # Retention shared by all jobs. pruneOpts = [ "--keep-daily 1" "--keep-weekly 7" "--keep-yearly 12" ]; in { options.mod.restic.enable = lib.mkEnableOption "Enable restic"; config = lib.mkIf enabled { fileSystems."/mnt/backup" = { device = "/dev/disk/by-uuid/34601701-65e6-4b2c-ac4d-8bef3dfd743f"; fsType = "ext4"; options = [ "nofail" ]; }; services.restic.backups = { "sync-to-external" = { initialize = true; passwordFile = config.age.secrets.restic-password.path; paths = [ "/mnt/sync/public" ]; repository = "/mnt/backup/restic"; timerConfig = { OnCalendar = "*-*-* 0/12:00:00"; Persistent = true; }; inherit pruneOpts; }; "sync-to-cloud" = { initialize = true; passwordFile = config.age.secrets.restic-password.path; environmentFile = config.age.secrets.restic-cloud-sync-key.path; repositoryFile = config.age.secrets.restic-cloud-sync-repository.path; paths = [ "/mnt/sync/public" ]; timerConfig = { OnCalendar = "*-*-* 0/12:00:00"; Persistent = true; }; inherit pruneOpts; }; # Service state (databases etc.). No timer of their own: the state-backup # orchestrator below runs the dump once and then triggers these in order, # so both back up the same consistent staging dir without racing it. "state-to-external" = { initialize = true; passwordFile = config.age.secrets.restic-password.path; paths = [ stateDir ]; repository = "/mnt/backup/restic"; timerConfig = null; inherit pruneOpts; }; "state-to-cloud" = { initialize = true; passwordFile = config.age.secrets.restic-password.path; environmentFile = config.age.secrets.restic-cloud-sync-key.path; repositoryFile = config.age.secrets.restic-cloud-sync-repository.path; paths = [ stateDir ]; timerConfig = null; inherit pruneOpts; }; }; # Dump service state, then push it to the external disk and the cloud, in # that order. A "-" prefix means a failure of one restic target does not # block the other; a failed dump (no prefix) aborts before either runs. systemd.services.state-backup = { description = "Dump service state and back it up (external + cloud)"; after = [ "podman.service" ]; serviceConfig = { Type = "oneshot"; ExecStart = [ "${stateDumpScript}" "-${pkgs.systemd}/bin/systemctl start --wait restic-backups-state-to-external.service" "-${pkgs.systemd}/bin/systemctl start --wait restic-backups-state-to-cloud.service" ]; }; }; systemd.timers.state-backup = { description = "Timer for service-state backups"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = "*-*-* 0/12:00:00"; Persistent = true; }; }; age.secrets = { "restic-password".file = ../../../../secrets/manatee/restic-password.age; "restic-cloud-sync-key".file = ../../../../secrets/manatee/restic-cloud-sync-key.age; "restic-cloud-sync-repository".file = ../../../../secrets/manatee/restic-cloud-sync-repository.age; }; }; }