# Backup Module # # Declarative restic backup configuration with systemd timers. # # Usage: # services.backup.enable = true; # services.backup.paths = [ "/home/user/Documents" "/home/user/notes" ]; # services.backup.repository = "b2:mybucket:backup"; { config, lib, pkgs, ... }: with lib; let cfg = config.services.backup; in { options.services.backup = { enable = mkEnableOption "automated backups with restic"; repository = mkOption { type = types.str; description = '' Restic repository location. Examples: - Local: /mnt/backup - SFTP: sftp:user@host:/path - B2: b2:bucket-name:path - S3: s3:s3.amazonaws.com/bucket-name - REST: rest:http://host:8000/ ''; example = "b2:mybucket:backup"; }; paths = mkOption { type = types.listOf types.str; default = []; description = "Paths to back up."; example = [ "/home/user/Documents" "/home/user/notes" "/var/lib/important" ]; }; exclude = mkOption { type = types.listOf types.str; default = [ "**/.git" "**/node_modules" "**/__pycache__" "**/.cache" "**/Cache" "**/.thumbnails" "**/Trash" "**/.local/share/Trash" ]; description = "Patterns to exclude from backup."; }; passwordFile = mkOption { type = types.str; description = "Path to file containing restic repository password."; example = "/run/secrets/restic-password"; }; environmentFile = mkOption { type = types.nullOr types.str; default = null; description = '' Path to file containing environment variables for cloud storage. Should contain variables like B2_ACCOUNT_ID, B2_ACCOUNT_KEY, etc. ''; example = "/run/secrets/restic-env"; }; schedule = mkOption { type = types.str; default = "*-*-* 02:00:00"; # 2 AM daily description = "When to run backups (systemd calendar format)."; example = "*-*-* 04:00:00"; }; pruneSchedule = mkOption { type = types.str; default = "Sun *-*-* 03:00:00"; # Sunday 3 AM description = "When to prune old backups."; }; retention = { daily = mkOption { type = types.int; default = 7; description = "Number of daily backups to keep."; }; weekly = mkOption { type = types.int; default = 4; description = "Number of weekly backups to keep."; }; monthly = mkOption { type = types.int; default = 6; description = "Number of monthly backups to keep."; }; yearly = mkOption { type = types.int; default = 2; description = "Number of yearly backups to keep."; }; }; extraOptions = mkOption { type = types.listOf types.str; default = []; description = "Extra options to pass to restic backup."; example = [ "--verbose" "--exclude-caches" ]; }; user = mkOption { type = types.str; default = "root"; description = "User to run backups as."; }; notifyOnFailure = mkOption { type = types.bool; default = true; description = "Send notification on backup failure."; }; }; config = mkIf cfg.enable { # Install restic environment.systemPackages = [ pkgs.restic ]; # Backup service systemd.services.restic-backup = { description = "Restic Backup"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; serviceConfig = { Type = "oneshot"; User = cfg.user; ExecStart = let excludeArgs = concatMapStringsSep " " (e: "--exclude '${e}'") cfg.exclude; pathArgs = concatStringsSep " " cfg.paths; extraArgs = concatStringsSep " " cfg.extraOptions; in pkgs.writeShellScript "restic-backup" '' set -euo pipefail export RESTIC_REPOSITORY="${cfg.repository}" export RESTIC_PASSWORD_FILE="${cfg.passwordFile}" ${optionalString (cfg.environmentFile != null) "source ${cfg.environmentFile}"} echo "Starting backup at $(date)" ${pkgs.restic}/bin/restic backup \ ${excludeArgs} \ ${extraArgs} \ ${pathArgs} echo "Backup completed at $(date)" ''; # Security hardening PrivateTmp = true; ProtectSystem = "strict"; ReadWritePaths = [ "/tmp" ]; NoNewPrivileges = true; }; # Retry on failure unitConfig = { OnFailure = mkIf cfg.notifyOnFailure [ "backup-notify-failure@%n.service" ]; }; }; # Backup timer systemd.timers.restic-backup = { description = "Restic Backup Timer"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = cfg.schedule; Persistent = true; # Run missed backups RandomizedDelaySec = "30min"; # Spread load }; }; # Prune service systemd.services.restic-prune = { description = "Restic Prune Old Backups"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; serviceConfig = { Type = "oneshot"; User = cfg.user; ExecStart = pkgs.writeShellScript "restic-prune" '' set -euo pipefail export RESTIC_REPOSITORY="${cfg.repository}" export RESTIC_PASSWORD_FILE="${cfg.passwordFile}" ${optionalString (cfg.environmentFile != null) "source ${cfg.environmentFile}"} echo "Pruning old backups at $(date)" ${pkgs.restic}/bin/restic forget \ --keep-daily ${toString cfg.retention.daily} \ --keep-weekly ${toString cfg.retention.weekly} \ --keep-monthly ${toString cfg.retention.monthly} \ --keep-yearly ${toString cfg.retention.yearly} \ --prune echo "Prune completed at $(date)" ''; PrivateTmp = true; ProtectSystem = "strict"; NoNewPrivileges = true; }; }; # Prune timer systemd.timers.restic-prune = { description = "Restic Prune Timer"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = cfg.pruneSchedule; Persistent = true; }; }; # Failure notification service template systemd.services."backup-notify-failure@" = mkIf cfg.notifyOnFailure { description = "Backup Failure Notification"; serviceConfig = { Type = "oneshot"; ExecStart = pkgs.writeShellScript "backup-notify-failure" '' echo "Backup failed: %i at $(date)" | ${pkgs.util-linux}/bin/wall # Add your notification method here: # - Send email # - Push notification # - Slack webhook # - etc. ''; }; }; # Helper scripts environment.systemPackages = [ (pkgs.writeShellScriptBin "ubackup" '' # Unified backup helper case "''${1:-}" in now) echo "Starting backup..." sudo systemctl start restic-backup ;; status) echo "=== Recent backups ===" export RESTIC_REPOSITORY="${cfg.repository}" export RESTIC_PASSWORD_FILE="${cfg.passwordFile}" ${optionalString (cfg.environmentFile != null) "source ${cfg.environmentFile}"} ${pkgs.restic}/bin/restic snapshots --last 10 ;; restore) if [ -z "''${2:-}" ]; then echo "Usage: ubackup restore " exit 1 fi export RESTIC_REPOSITORY="${cfg.repository}" export RESTIC_PASSWORD_FILE="${cfg.passwordFile}" ${optionalString (cfg.environmentFile != null) "source ${cfg.environmentFile}"} ${pkgs.restic}/bin/restic restore "$2" --target "''${3:-.}" ;; check) echo "Checking backup integrity..." export RESTIC_REPOSITORY="${cfg.repository}" export RESTIC_PASSWORD_FILE="${cfg.passwordFile}" ${optionalString (cfg.environmentFile != null) "source ${cfg.environmentFile}"} ${pkgs.restic}/bin/restic check ;; *) echo "Usage: ubackup {now|status|restore|check}" echo "" echo "Commands:" echo " now Run backup immediately" echo " status Show recent backups" echo " restore ID PATH Restore snapshot to path" echo " check Verify backup integrity" ;; esac '') ]; }; }