{ config, lib, pkgs, utils, ... }: with lib; let # Type for a valid systemd unit option. Needed for correctly passing "timerConfig" to "systemd.timers" inherit (utils.systemdUtils.unitOptions) unitOption; in { options.services.restic.backups = mkOption { description = lib.mdDoc '' Periodic backups to create with Restic. ''; type = types.attrsOf (types.submodule ({ config, name, ... }: { options = { passwordFile = mkOption { type = types.str; description = lib.mdDoc '' Read the repository password from a file. ''; example = "/etc/nixos/restic-password"; }; environmentFile = mkOption { type = with types; nullOr str; default = null; description = lib.mdDoc '' file containing the credentials to access the repository, in the format of an EnvironmentFile as described by systemd.exec(5) ''; }; rcloneOptions = mkOption { type = with types; nullOr (attrsOf (oneOf [ str bool ])); default = null; description = lib.mdDoc '' Options to pass to rclone to control its behavior. See for available options. When specifying option names, strip the leading `--`. To set a flag such as `--drive-use-trash`, which does not take a value, set the value to the Boolean `true`. ''; example = { bwlimit = "10M"; drive-use-trash = "true"; }; }; rcloneConfig = mkOption { type = with types; nullOr (attrsOf (oneOf [ str bool ])); default = null; description = lib.mdDoc '' Configuration for the rclone remote being used for backup. See the remote's specific options under rclone's docs at . When specifying option names, use the "config" name specified in the docs. For example, to set `--b2-hard-delete` for a B2 remote, use `hard_delete = true` in the attribute set. Warning: Secrets set in here will be world-readable in the Nix store! Consider using the `rcloneConfigFile` option instead to specify secret values separately. Note that options set here will override those set in the config file. ''; example = { type = "b2"; account = "xxx"; key = "xxx"; hard_delete = true; }; }; rcloneConfigFile = mkOption { type = with types; nullOr path; default = null; description = lib.mdDoc '' Path to the file containing rclone configuration. This file must contain configuration for the remote specified in this backup set and also must be readable by root. Options set in `rcloneConfig` will override those set in this file. ''; }; repository = mkOption { type = with types; nullOr str; default = null; description = lib.mdDoc '' repository to backup to. ''; example = "sftp:backup@192.168.1.100:/backups/${name}"; }; repositoryFile = mkOption { type = with types; nullOr path; default = null; description = lib.mdDoc '' Path to the file containing the repository location to backup to. ''; }; paths = mkOption { # This is nullable for legacy reasons only. We should consider making it a pure listOf # after some time has passed since this comment was added. type = types.nullOr (types.listOf types.str); default = [ ]; description = lib.mdDoc '' Which paths to backup, in addition to ones specified via `dynamicFilesFrom`. If null or an empty array and `dynamicFilesFrom` is also null, no backup command will be run. This can be used to create a prune-only job. ''; example = [ "/var/lib/postgresql" "/home/user/backup" ]; }; exclude = mkOption { type = types.listOf types.str; default = [ ]; description = lib.mdDoc '' Patterns to exclude when backing up. See https://restic.readthedocs.io/en/latest/040_backup.html#excluding-files for details on syntax. ''; example = [ "/var/cache" "/home/*/.cache" ".git" ]; }; timerConfig = mkOption { type = types.nullOr (types.attrsOf unitOption); default = { OnCalendar = "daily"; Persistent = true; }; description = lib.mdDoc '' When to run the backup. See {manpage}`systemd.timer(5)` for details. If null no timer is created and the backup will only run when explicitly started. ''; example = { OnCalendar = "00:05"; RandomizedDelaySec = "5h"; Persistent = true; }; }; user = mkOption { type = types.str; default = "root"; description = lib.mdDoc '' As which user the backup should run. ''; example = "postgresql"; }; extraBackupArgs = mkOption { type = types.listOf types.str; default = [ ]; description = lib.mdDoc '' Extra arguments passed to restic backup. ''; example = [ "--exclude-file=/etc/nixos/restic-ignore" ]; }; extraOptions = mkOption { type = types.listOf types.str; default = [ ]; description = lib.mdDoc '' Extra extended options to be passed to the restic --option flag. ''; example = [ "sftp.command='ssh backup@192.168.1.100 -i /home/user/.ssh/id_rsa -s sftp'" ]; }; initialize = mkOption { type = types.bool; default = false; description = lib.mdDoc '' Create the repository if it doesn't exist. ''; }; pruneOpts = mkOption { type = types.listOf types.str; default = [ ]; description = lib.mdDoc '' A list of options (--keep-\* et al.) for 'restic forget --prune', to automatically prune old snapshots. The 'forget' command is run *after* the 'backup' command, so keep that in mind when constructing the --keep-\* options. ''; example = [ "--keep-daily 7" "--keep-weekly 5" "--keep-monthly 12" "--keep-yearly 75" ]; }; checkOpts = mkOption { type = types.listOf types.str; default = [ ]; description = lib.mdDoc '' A list of options for 'restic check', which is run after pruning. ''; example = [ "--with-cache" ]; }; dynamicFilesFrom = mkOption { type = with types; nullOr str; default = null; description = lib.mdDoc '' A script that produces a list of files to back up. The results of this command are given to the '--files-from' option. The result is merged with paths specified via `paths`. ''; example = "find /home/matt/git -type d -name .git"; }; backupPrepareCommand = mkOption { type = with types; nullOr str; default = null; description = lib.mdDoc '' A script that must run before starting the backup process. ''; }; backupCleanupCommand = mkOption { type = with types; nullOr str; default = null; description = lib.mdDoc '' A script that must run after finishing the backup process. ''; }; package = mkOption { type = types.package; default = pkgs.restic; defaultText = literalExpression "pkgs.restic"; description = lib.mdDoc '' Restic package to use. ''; }; createWrapper = lib.mkOption { type = lib.types.bool; default = true; description = '' Whether to generate and add a script to the system path, that has the same environment variables set as the systemd service. This can be used to e.g. mount snapshots or perform other opterations, without having to manually specify most options. ''; }; }; })); default = { }; example = { localbackup = { paths = [ "/home" ]; exclude = [ "/home/*/.cache" ]; repository = "/mnt/backup-hdd"; passwordFile = "/etc/nixos/secrets/restic-password"; initialize = true; }; remotebackup = { paths = [ "/home" ]; repository = "sftp:backup@host:/backups/home"; passwordFile = "/etc/nixos/secrets/restic-password"; extraOptions = [ "sftp.command='ssh backup@host -i /etc/nixos/secrets/backup-private-key -s sftp'" ]; timerConfig = { OnCalendar = "00:05"; RandomizedDelaySec = "5h"; }; }; }; }; config = { assertions = mapAttrsToList (n: v: { assertion = (v.repository == null) != (v.repositoryFile == null); message = "services.restic.backups.${n}: exactly one of repository or repositoryFile should be set"; }) config.services.restic.backups; systemd.services = mapAttrs' (name: backup: let extraOptions = concatMapStrings (arg: " -o ${arg}") backup.extraOptions; resticCmd = "${backup.package}/bin/restic${extraOptions}"; excludeFlags = optional (backup.exclude != []) "--exclude-file=${pkgs.writeText "exclude-patterns" (concatStringsSep "\n" backup.exclude)}"; filesFromTmpFile = "/run/restic-backups-${name}/includes"; doBackup = (backup.dynamicFilesFrom != null) || (backup.paths != null && backup.paths != []); pruneCmd = optionals (builtins.length backup.pruneOpts > 0) [ (resticCmd + " forget --prune " + (concatStringsSep " " backup.pruneOpts)) (resticCmd + " check " + (concatStringsSep " " backup.checkOpts)) ]; # Helper functions for rclone remotes rcloneRemoteName = builtins.elemAt (splitString ":" backup.repository) 1; rcloneAttrToOpt = v: "RCLONE_" + toUpper (builtins.replaceStrings [ "-" ] [ "_" ] v); rcloneAttrToConf = v: "RCLONE_CONFIG_" + toUpper (rcloneRemoteName + "_" + v); toRcloneVal = v: if lib.isBool v then lib.boolToString v else v; in nameValuePair "restic-backups-${name}" ({ environment = { # not %C, because that wouldn't work in the wrapper script RESTIC_CACHE_DIR = "/var/cache/restic-backups-${name}"; RESTIC_PASSWORD_FILE = backup.passwordFile; RESTIC_REPOSITORY = backup.repository; RESTIC_REPOSITORY_FILE = backup.repositoryFile; } // optionalAttrs (backup.rcloneOptions != null) (mapAttrs' (name: value: nameValuePair (rcloneAttrToOpt name) (toRcloneVal value) ) backup.rcloneOptions) // optionalAttrs (backup.rcloneConfigFile != null) { RCLONE_CONFIG = backup.rcloneConfigFile; } // optionalAttrs (backup.rcloneConfig != null) (mapAttrs' (name: value: nameValuePair (rcloneAttrToConf name) (toRcloneVal value) ) backup.rcloneConfig); path = [ config.programs.ssh.package ]; restartIfChanged = false; wants = [ "network-online.target" ]; after = [ "network-online.target" ]; serviceConfig = { Type = "oneshot"; ExecStart = (optionals doBackup [ "${resticCmd} backup ${concatStringsSep " " (backup.extraBackupArgs ++ excludeFlags)} --files-from=${filesFromTmpFile}" ]) ++ pruneCmd; User = backup.user; RuntimeDirectory = "restic-backups-${name}"; CacheDirectory = "restic-backups-${name}"; CacheDirectoryMode = "0700"; PrivateTmp = true; } // optionalAttrs (backup.environmentFile != null) { EnvironmentFile = backup.environmentFile; }; } // optionalAttrs (backup.initialize || doBackup || backup.backupPrepareCommand != null) { preStart = '' ${optionalString (backup.backupPrepareCommand != null) '' ${pkgs.writeScript "backupPrepareCommand" backup.backupPrepareCommand} ''} ${optionalString (backup.initialize) '' ${resticCmd} snapshots || ${resticCmd} init ''} ${optionalString (backup.paths != null && backup.paths != []) '' cat ${pkgs.writeText "staticPaths" (concatStringsSep "\n" backup.paths)} >> ${filesFromTmpFile} ''} ${optionalString (backup.dynamicFilesFrom != null) '' ${pkgs.writeScript "dynamicFilesFromScript" backup.dynamicFilesFrom} >> ${filesFromTmpFile} ''} ''; } // optionalAttrs (doBackup || backup.backupCleanupCommand != null) { postStop = '' ${optionalString (backup.backupCleanupCommand != null) '' ${pkgs.writeScript "backupCleanupCommand" backup.backupCleanupCommand} ''} ${optionalString doBackup '' rm ${filesFromTmpFile} ''} ''; }) ) config.services.restic.backups; systemd.timers = mapAttrs' (name: backup: nameValuePair "restic-backups-${name}" { wantedBy = [ "timers.target" ]; timerConfig = backup.timerConfig; }) (filterAttrs (_: backup: backup.timerConfig != null) config.services.restic.backups); # generate wrapper scripts, as described in the createWrapper option environment.systemPackages = lib.mapAttrsToList (name: backup: let extraOptions = lib.concatMapStrings (arg: " -o ${arg}") backup.extraOptions; resticCmd = "${backup.package}/bin/restic${extraOptions}"; in pkgs.writeShellScriptBin "restic-${name}" '' set -a # automatically export variables ${lib.optionalString (backup.environmentFile != null) "source ${backup.environmentFile}"} # set same environment variables as the systemd service ${lib.pipe config.systemd.services."restic-backups-${name}".environment [ (lib.filterAttrs (_: v: v != null)) (lib.mapAttrsToList (n: v: "${n}=${v}")) (lib.concatStringsSep "\n") ]} exec ${resticCmd} $@ '') (lib.filterAttrs (_: v: v.createWrapper) config.services.restic.backups); }; }