diff --git a/nixos/backups.nix b/nixos/backups.nix
index d8bd51a5..57cbd0a4 100644
--- a/nixos/backups.nix
+++ b/nixos/backups.nix
@@ -9,6 +9,8 @@ let
];
in {
+ disabledModules = [ "services/backup/restic.nix" ];
+ imports = [ services/backup/restic.nix ];
services = {
restic.backups.home-to-bolty = {
passwordFile = "/etc/nixos/secrets/restic-password-bolty";
@@ -16,6 +18,8 @@ in {
repository = "rest:http://bolty:8000/";
timerConfig = { OnCalendar = "hourly"; };
extraBackupArgs = extraArgs;
+ niceness = 19;
+ ioSchedulingClass = "idle";
};
restic.backups.home-to-b2 = {
@@ -25,6 +29,8 @@ in {
timerConfig = { OnCalendar = "hourly"; };
extraBackupArgs = extraArgs;
environmentFile = "/etc/nixos/secrets/b2-env";
+ niceness = 19;
+ ioSchedulingClass = "idle";
};
};
}
diff --git a/nixos/services/backup/restic.nix b/nixos/services/backup/restic.nix
new file mode 100644
index 00000000..e55161eb
--- /dev/null
+++ b/nixos/services/backup/restic.nix
@@ -0,0 +1,352 @@
+{ 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 = ''
+ Periodic backups to create with Restic.
+ '';
+ type = types.attrsOf (types.submodule ({ config, name, ... }: {
+ options = {
+ passwordFile = mkOption {
+ type = types.str;
+ description = ''
+ Read the repository password from a file.
+ '';
+ example = "/etc/nixos/restic-password";
+ };
+
+ environmentFile = mkOption {
+ type = with types; nullOr str;
+ # added on 2021-08-28, s3CredentialsFile should
+ # be removed in the future (+ remember the warning)
+ default = config.s3CredentialsFile;
+ description = ''
+ file containing the credentials to access the repository, in the
+ format of an EnvironmentFile as described by systemd.exec(5)
+ '';
+ };
+
+ s3CredentialsFile = mkOption {
+ type = with types; nullOr str;
+ default = null;
+ description = ''
+ file containing the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
+ for an S3-hosted 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 = ''
+ 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 = ''
+ 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 = ''
+ 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 = ''
+ repository to backup to.
+ '';
+ example = "sftp:backup@192.168.1.100:/backups/${name}";
+ };
+
+ repositoryFile = mkOption {
+ type = with types; nullOr path;
+ default = null;
+ description = ''
+ Path to the file containing the repository location to backup to.
+ '';
+ };
+
+ paths = mkOption {
+ type = types.nullOr (types.listOf types.str);
+ default = null;
+ description = ''
+ Which paths to backup. If null or an empty array, no
+ backup command will be run. This can be used to create a
+ prune-only job.
+ '';
+ example = [ "/var/lib/postgresql" "/home/user/backup" ];
+ };
+
+ timerConfig = mkOption {
+ type = types.attrsOf unitOption;
+ default = { OnCalendar = "daily"; };
+ description = ''
+ When to run the backup. See man systemd.timer for details.
+ '';
+ example = {
+ OnCalendar = "00:05";
+ RandomizedDelaySec = "5h";
+ };
+ };
+
+ user = mkOption {
+ type = types.str;
+ default = "root";
+ description = ''
+ As which user the backup should run.
+ '';
+ example = "postgresql";
+ };
+
+ extraBackupArgs = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = ''
+ Extra arguments passed to restic backup.
+ '';
+ example = [ "--exclude-file=/etc/nixos/restic-ignore" ];
+ };
+
+ extraOptions = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = ''
+ 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 = ''
+ Create the repository if it doesn't exist.
+ '';
+ };
+
+ pruneOpts = mkOption {
+ type = types.listOf types.str;
+ default = [ ];
+ description = ''
+ 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"
+ ];
+ };
+
+ dynamicFilesFrom = mkOption {
+ type = with types; nullOr str;
+ default = null;
+ description = ''
+ A script that produces a list of files to back up. The
+ results of this command are given to the '--files-from'
+ option.
+ '';
+ example = "find /home/matt/git -type d -name .git";
+ };
+
+ backupPrepareCommand = mkOption {
+ type = with types; nullOr str;
+ default = null;
+ description = ''
+ A script that must run before starting the backup process.
+ '';
+ };
+
+ backupCleanupCommand = mkOption {
+ type = with types; nullOr str;
+ default = null;
+ description = ''
+ A script that must run after finishing the backup process.
+ '';
+ };
+ niceness = mkOption {
+ description =
+ "Niceness for local instances of btrbk. Also applies to remote ones connecting via ssh when positive.";
+ type = types.ints.between (-20) 19;
+ default = 10;
+ };
+ ioSchedulingClass = mkOption {
+ description =
+ "IO scheduling class for btrbk (see ionice(1) for a quick description). Applies to local instances, and remote ones connecting by ssh if set to idle.";
+ type = types.enum [ "idle" "best-effort" "realtime" ];
+ default = "best-effort";
+ };
+ };
+ }));
+ default = { };
+ example = {
+ localbackup = {
+ paths = [ "/home" ];
+ 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 = {
+ warnings = mapAttrsToList (n: v:
+ "services.restic.backups.${n}.s3CredentialsFile is deprecated, please use services.restic.backups.${n}.environmentFile instead.")
+ (filterAttrs (n: v: v.s3CredentialsFile != null)
+ config.services.restic.backups);
+ systemd.services = mapAttrs' (name: backup:
+ let
+ extraOptions = concatMapStrings (arg: " -o ${arg}") backup.extraOptions;
+ resticCmd = "${pkgs.restic}/bin/restic${extraOptions}";
+ filesFromTmpFile = "/run/restic-backups-${name}/includes";
+ backupPaths = if (backup.dynamicFilesFrom == null) then
+ if (backup.paths != null) then
+ concatStringsSep " " backup.paths
+ else
+ ""
+ else
+ "--files-from ${filesFromTmpFile}";
+ pruneCmd = optionals (builtins.length backup.pruneOpts > 0) [
+ (resticCmd + " forget --prune "
+ + (concatStringsSep " " backup.pruneOpts))
+ (resticCmd + " check")
+ ];
+ # 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 = {
+ 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 = [ pkgs.openssh ];
+ restartIfChanged = false;
+ serviceConfig = {
+ Type = "oneshot";
+ ExecStart = (optionals (backupPaths != "") [
+ "${resticCmd} backup --cache-dir=%C/restic-backups-${name} ${
+ concatStringsSep " " backup.extraBackupArgs
+ } ${backupPaths}"
+ ]) ++ pruneCmd;
+ User = backup.user;
+ RuntimeDirectory = "restic-backups-${name}";
+ CacheDirectory = "restic-backups-${name}";
+ CacheDirectoryMode = "0700";
+ Nice = backup.niceness;
+ IOSchedulingClass = backup.ioSchedulingClass;
+ } // optionalAttrs (backup.environmentFile != null) {
+ EnvironmentFile = backup.environmentFile;
+ };
+ } // optionalAttrs (backup.initialize || backup.dynamicFilesFrom != null
+ || backup.backupPrepareCommand != null) {
+ preStart = ''
+ ${optionalString (backup.backupPrepareCommand != null) ''
+ ${pkgs.writeScript "backupPrepareCommand"
+ backup.backupPrepareCommand}
+ ''}
+ ${optionalString (backup.initialize) ''
+ ${resticCmd} snapshots || ${resticCmd} init
+ ''}
+ ${optionalString (backup.dynamicFilesFrom != null) ''
+ ${
+ pkgs.writeScript "dynamicFilesFromScript"
+ backup.dynamicFilesFrom
+ } > ${filesFromTmpFile}
+ ''}
+ '';
+ } // optionalAttrs (backup.dynamicFilesFrom != null
+ || backup.backupCleanupCommand != null) {
+ postStart = ''
+ ${optionalString (backup.backupCleanupCommand != null) ''
+ ${pkgs.writeScript "backupCleanupCommand"
+ backup.backupCleanupCommand}
+ ''}
+ ${optionalString (backup.dynamicFilesFrom != null) ''
+ rm ${filesFromTmpFile}
+ ''}
+ '';
+ })) config.services.restic.backups;
+ systemd.timers = mapAttrs' (name: backup:
+ nameValuePair "restic-backups-${name}" {
+ wantedBy = [ "timers.target" ];
+ timerConfig = backup.timerConfig;
+ }) config.services.restic.backups;
+ };
+}