1 { config, lib, pkgs, ... }:
5 builtins.substring 0 1 x == "/" # absolute path
6 || builtins.substring 0 1 x == "." # relative path
7 || builtins.match "[.*:.*]" == null; # not machine:path
10 # Write each exclude pattern to a new line
11 pkgs.writeText "excludefile" (lib.concatMapStrings (s: s + "\n") cfg.exclude);
14 # Write each pattern to a new line
15 pkgs.writeText "patternsfile" (lib.concatMapStrings (s: s + "\n") cfg.patterns);
18 # If cfg.prune.keep e.g. has a yearly attribute,
19 # its content is passed on as --keep-yearly
20 lib.concatStringsSep " "
21 (lib.mapAttrsToList (x: y: "--keep-${x}=${toString y}") cfg.prune.keep);
23 mkBackupScript = name: cfg: pkgs.writeShellScript "${name}-script" (''
35 borg "$@" && result=$? || result=$?
36 if [[ -z "${toString cfg.failOnWarnings}" ]] && [[ "$result" == 1 ]]; then
37 echo "ignoring warning return value 1"
44 archiveName="${lib.optionalString (cfg.archiveBaseName != null) (cfg.archiveBaseName + "-")}$(date ${cfg.dateFormat})"
45 archiveSuffix="${lib.optionalString cfg.appendFailedSuffix ".failed"}"
47 '' + lib.optionalString cfg.doInit ''
48 # Run borg init if the repo doesn't exist yet
49 if ! borgWrapper list $extraArgs > /dev/null; then
50 borgWrapper init $extraArgs \
51 --encryption ${cfg.encryption.mode} \
58 ${lib.optionalString (cfg.dumpCommand != null) ''${lib.escapeShellArg cfg.dumpCommand} | \''}
59 borgWrapper create $extraArgs \
60 --compression ${cfg.compression} \
61 --exclude-from ${mkExcludeFile cfg} \
62 --patterns-from ${mkPatternsFile cfg} \
64 "::$archiveName$archiveSuffix" \
65 ${if cfg.paths == null then "-" else lib.escapeShellArgs cfg.paths}
67 '' + lib.optionalString cfg.appendFailedSuffix ''
68 borgWrapper rename $extraArgs \
69 "::$archiveName$archiveSuffix" "$archiveName"
72 '' + lib.optionalString (cfg.prune.keep != { }) ''
73 borgWrapper prune $extraArgs \
75 ${lib.optionalString (cfg.prune.prefix != null) "--glob-archives ${lib.escapeShellArg "${cfg.prune.prefix}*"}"} \
77 borgWrapper compact $extraArgs $extraCompactArgs
81 mkPassEnv = cfg: with cfg.encryption;
82 if passCommand != null then
83 { BORG_PASSCOMMAND = passCommand; }
84 else if passphrase != null then
85 { BORG_PASSPHRASE = passphrase; }
88 mkBackupService = name: cfg:
90 userHome = config.users.users.${cfg.user}.home;
91 backupJobName = "borgbackup-job-${name}";
92 backupScript = mkBackupScript backupJobName cfg;
93 in lib.nameValuePair backupJobName {
94 description = "BorgBackup job ${name}";
96 config.services.borgbackup.package pkgs.openssh
98 script = "exec " + lib.optionalString cfg.inhibitsSleep ''\
99 ${pkgs.systemd}/bin/systemd-inhibit \
102 --why="Scheduled backup" \
104 unitConfig = lib.optionalAttrs (isLocalPath cfg.repo) {
105 RequiresMountsFor = [ cfg.repo ];
110 # Only run when no other process is using CPU or disk
111 CPUSchedulingPolicy = "idle";
112 IOSchedulingClass = "idle";
113 ProtectSystem = "strict";
115 [ "${userHome}/.config/borg" "${userHome}/.cache/borg" ]
116 ++ cfg.readWritePaths
117 # Borg needs write access to repo if it is not remote
118 ++ lib.optional (isLocalPath cfg.repo) cfg.repo;
119 PrivateTmp = cfg.privateTmp;
122 BORG_REPO = cfg.repo;
123 inherit (cfg) extraArgs extraInitArgs extraCreateArgs extraPruneArgs extraCompactArgs;
124 } // (mkPassEnv cfg) // cfg.environment;
127 mkBackupTimers = name: cfg:
128 lib.nameValuePair "borgbackup-job-${name}" {
129 description = "BorgBackup job ${name} timer";
130 wantedBy = [ "timers.target" ];
132 Persistent = cfg.persistentTimer;
133 OnCalendar = cfg.startAt;
135 # if remote-backup wait for network
136 after = lib.optional (cfg.persistentTimer && !isLocalPath cfg.repo) "network-online.target";
137 wants = lib.optional (cfg.persistentTimer && !isLocalPath cfg.repo) "network-online.target";
140 # utility function around makeWrapper
142 original, name, set ? {}
144 pkgs.runCommand "${name}-wrapper" {
145 nativeBuildInputs = [ pkgs.makeWrapper ];
147 makeWrapper "${original}" "$out/bin/${name}" \
148 ${lib.concatStringsSep " \\\n " (lib.mapAttrsToList (name: value: ''--set ${name} "${value}"'') set)}
151 mkBorgWrapper = name: cfg: mkWrapperDrv {
152 original = lib.getExe config.services.borgbackup.package;
153 name = "borg-job-${name}";
154 set = { BORG_REPO = cfg.repo; } // (mkPassEnv cfg) // cfg.environment;
157 # Paths listed in ReadWritePaths must exist before service is started
158 mkTmpfiles = name: cfg:
160 settings = { inherit (cfg) user group; };
161 in lib.nameValuePair "borgbackup-job-${name}" ({
162 # Create parent dirs separately, to ensure correct ownership.
163 "${config.users.users."${cfg.user}".home}/.config".d = settings;
164 "${config.users.users."${cfg.user}".home}/.cache".d = settings;
165 "${config.users.users."${cfg.user}".home}/.config/borg".d = settings;
166 "${config.users.users."${cfg.user}".home}/.cache/borg".d = settings;
167 } // lib.optionalAttrs (isLocalPath cfg.repo && !cfg.removableDevice) {
168 "${cfg.repo}".d = settings;
171 mkPassAssertion = name: cfg: {
172 assertion = with cfg.encryption;
173 mode != "none" -> passCommand != null || passphrase != null;
175 "passCommand or passphrase has to be specified because"
176 + '' borgbackup.jobs.${name}.encryption != "none"'';
179 mkRepoService = name: cfg:
180 lib.nameValuePair "borgbackup-repo-${name}" {
181 description = "Create BorgBackup repository ${name} directory";
183 mkdir -p ${lib.escapeShellArg cfg.path}
184 chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg cfg.path}
187 # The service's only task is to ensure that the specified path exists
190 wantedBy = [ "multi-user.target" ];
193 mkAuthorizedKey = cfg: appendOnly: key:
195 # Because of the following line, clients do not need to specify an absolute repo path
196 cdCommand = "cd ${lib.escapeShellArg cfg.path}";
197 restrictedArg = "--restrict-to-${if cfg.allowSubRepos then "path" else "repository"} .";
198 appendOnlyArg = lib.optionalString appendOnly "--append-only";
199 quotaArg = lib.optionalString (cfg.quota != null) "--storage-quota ${cfg.quota}";
200 serveCommand = "borg serve ${restrictedArg} ${appendOnlyArg} ${quotaArg}";
202 ''command="${cdCommand} && ${serveCommand}",restrict ${key}'';
204 mkUsersConfig = name: cfg: {
205 users.${cfg.user} = {
206 openssh.authorizedKeys.keys =
207 (map (mkAuthorizedKey cfg false) cfg.authorizedKeys
208 ++ map (mkAuthorizedKey cfg true) cfg.authorizedKeysAppendOnly);
209 useDefaultShell = true;
213 groups.${cfg.group} = { };
216 mkKeysAssertion = name: cfg: {
217 assertion = cfg.authorizedKeys != [ ] || cfg.authorizedKeysAppendOnly != [ ];
219 "borgbackup.repos.${name} does not make sense"
220 + " without at least one public key";
223 mkSourceAssertions = name: cfg: {
224 assertion = lib.count isNull [ cfg.dumpCommand cfg.paths ] == 1;
226 Exactly one of borgbackup.jobs.${name}.paths or borgbackup.jobs.${name}.dumpCommand
231 mkRemovableDeviceAssertions = name: cfg: {
232 assertion = !(isLocalPath cfg.repo) -> !cfg.removableDevice;
234 borgbackup.repos.${name}: repo isn't a local path, thus it can't be a removable device!
239 meta.maintainers = with lib.maintainers; [ dotlambda ];
240 meta.doc = ./borgbackup.md;
244 options.services.borgbackup.package = lib.mkPackageOption pkgs "borgbackup" { };
246 options.services.borgbackup.jobs = lib.mkOption {
248 Deduplicating backups using BorgBackup.
249 Adding a job will cause a borg-job-NAME wrapper to be added
250 to your system path, so that you can perform maintenance easily.
251 See also the chapter about BorgBackup in the NixOS manual.
254 example = lib.literalExpression ''
255 { # for a local backup
258 exclude = [ "/nix" ];
259 repo = "/path/to/local/repo";
262 passphrase = "secret";
264 compression = "auto,lzma";
268 { # Root backing each day up to a remote backup server. We assume that you have
269 # * created a password less key: ssh-keygen -N "" -t ed25519 -f /path/to/ssh_key
270 # best practices are: use -t ed25519, /path/to = /run/keys
271 # * the passphrase is in the file /run/keys/borgbackup_passphrase
272 # * you have initialized the repository manually
273 paths = [ "/etc" "/home" ];
274 exclude = [ "/nix" "'**/.cache'" ];
276 repo = "user3@arep.repo.borgbase.com:repo";
278 mode = "repokey-blake2";
279 passCommand = "cat /path/to/passphrase";
281 environment = { BORG_RSH = "ssh -i /path/to/ssh_key"; };
282 compression = "auto,lzma";
286 type = lib.types.attrsOf (lib.types.submodule (let globalConfig = config; in
287 { name, config, ... }: {
290 paths = lib.mkOption {
291 type = with lib.types; nullOr (coercedTo str lib.singleton (listOf str));
295 Mutually exclusive with {option}`dumpCommand`.
297 example = "/home/user";
300 dumpCommand = lib.mkOption {
301 type = with lib.types; nullOr path;
304 Backup the stdout of this program instead of filesystem paths.
305 Mutually exclusive with {option}`paths`.
307 example = "/path/to/createZFSsend.sh";
310 repo = lib.mkOption {
311 type = lib.types.str;
312 description = "Remote or local repository to back up to.";
313 example = "user@machine:/path/to/repo";
316 removableDevice = lib.mkOption {
317 type = lib.types.bool;
319 description = "Whether the repo (which must be local) is a removable device.";
322 archiveBaseName = lib.mkOption {
323 type = lib.types.nullOr (lib.types.strMatching "[^/{}]+");
324 default = "${globalConfig.networking.hostName}-${name}";
325 defaultText = lib.literalExpression ''"''${config.networking.hostName}-<name>"'';
327 How to name the created archives. A timestamp, whose format is
328 determined by {option}`dateFormat`, will be appended. The full
329 name can be modified at runtime (`$archiveName`).
330 Placeholders like `{hostname}` must not be used.
331 Use `null` for no base name.
335 dateFormat = lib.mkOption {
336 type = lib.types.str;
338 Arguments passed to {command}`date`
339 to create a timestamp suffix for the archive name.
341 default = "+%Y-%m-%dT%H:%M:%S";
345 startAt = lib.mkOption {
346 type = with lib.types; either str (listOf str);
349 When or how often the backup should run.
350 Must be in the format described in
351 {manpage}`systemd.time(7)`.
352 If you do not want the backup to start
353 automatically, use `[ ]`.
354 It will generate a systemd service borgbackup-job-NAME.
355 You may trigger it manually via systemctl restart borgbackup-job-NAME.
359 persistentTimer = lib.mkOption {
361 type = lib.types.bool;
364 Set the `Persistent` option for the
365 {manpage}`systemd.timer(5)`
366 which triggers the backup immediately if the last trigger
367 was missed (e.g. if the system was powered down).
371 inhibitsSleep = lib.mkOption {
373 type = lib.types.bool;
376 Prevents the system from sleeping while backing up.
380 user = lib.mkOption {
381 type = lib.types.str;
383 The user {command}`borg` is run as.
384 User or group need read permission
385 for the specified {option}`paths`.
390 group = lib.mkOption {
391 type = lib.types.str;
393 The group borg is run as. User or group needs read permission
394 for the specified {option}`paths`.
399 encryption.mode = lib.mkOption {
400 type = lib.types.enum [
402 "repokey-blake2" "keyfile-blake2"
403 "authenticated" "authenticated-blake2"
407 Encryption mode to use. Setting a mode
408 other than `"none"` requires
409 you to specify a {option}`passCommand`
410 or a {option}`passphrase`.
412 example = "repokey-blake2";
415 encryption.passCommand = lib.mkOption {
416 type = with lib.types; nullOr str;
418 A command which prints the passphrase to stdout.
419 Mutually exclusive with {option}`passphrase`.
422 example = "cat /path/to/passphrase_file";
425 encryption.passphrase = lib.mkOption {
426 type = with lib.types; nullOr str;
428 The passphrase the backups are encrypted with.
429 Mutually exclusive with {option}`passCommand`.
430 If you do not want the passphrase to be stored in the
431 world-readable Nix store, use {option}`passCommand`.
436 compression = lib.mkOption {
437 # "auto" is optional,
438 # compression mode must be given,
439 # compression level is optional
440 type = lib.types.strMatching "none|(auto,)?(lz4|zstd|zlib|lzma)(,[[:digit:]]{1,2})?";
442 Compression method to use. Refer to
443 {command}`borg help compression`
444 for all available options.
447 example = "auto,lzma";
450 exclude = lib.mkOption {
451 type = with lib.types; listOf str;
453 Exclude paths matching any of the given patterns. See
454 {command}`borg help patterns` for pattern syntax.
463 patterns = lib.mkOption {
464 type = with lib.types; listOf str;
466 Include/exclude paths matching the given patterns. The first
467 matching patterns is used, so if an include pattern (prefix `+`)
468 matches before an exclude pattern (prefix `-`), the file is
469 backed up. See [{command}`borg help patterns`](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-patterns) for pattern syntax.
478 readWritePaths = lib.mkOption {
479 type = with lib.types; listOf path;
481 By default, borg cannot write anywhere on the system but
482 `$HOME/.config/borg` and `$HOME/.cache/borg`.
483 If, for example, your preHook script needs to dump files
484 somewhere, put those directories here.
488 "/var/backup/mysqldump"
492 privateTmp = lib.mkOption {
493 type = lib.types.bool;
495 Set the `PrivateTmp` option for
496 the systemd-service. Set to false if you need sockets
497 or other files from global /tmp.
502 failOnWarnings = lib.mkOption {
503 type = lib.types.bool;
505 Fail the whole backup job if any borg command returns a warning
506 (exit code 1), for example because a file changed during backup.
511 doInit = lib.mkOption {
512 type = lib.types.bool;
514 Run {command}`borg init` if the
515 specified {option}`repo` does not exist.
516 You should set this to `false`
517 if the repository is located on an external drive
518 that might not always be mounted.
523 appendFailedSuffix = lib.mkOption {
524 type = lib.types.bool;
526 Append a `.failed` suffix
527 to the archive name, which is only removed if
528 {command}`borg create` has a zero exit status.
533 prune.keep = lib.mkOption {
534 # Specifying e.g. `prune.keep.yearly = -1`
535 # means there is no limit of yearly archives to keep
536 # The regex is for use with e.g. --keep-within 1y
537 type = with lib.types; attrsOf (either int (strMatching "[[:digit:]]+[Hdwmy]"));
539 Prune a repository by deleting all archives not matching any of the
540 specified retention options. See {command}`borg help prune`
541 for the available options.
544 example = lib.literalExpression ''
546 within = "1d"; # Keep all archives from the last day
549 monthly = -1; # Keep at least one archive for each month
554 prune.prefix = lib.mkOption {
555 type = lib.types.nullOr (lib.types.str);
557 Only consider archive names starting with this prefix for pruning.
558 By default, only archives created by this job are considered.
559 Use `""` or `null` to consider all archives.
561 default = config.archiveBaseName;
562 defaultText = lib.literalExpression "archiveBaseName";
565 environment = lib.mkOption {
566 type = with lib.types; attrsOf str;
568 Environment variables passed to the backup script.
569 You can for example specify which SSH key to use.
572 example = { BORG_RSH = "ssh -i /path/to/key"; };
575 preHook = lib.mkOption {
576 type = lib.types.lines;
578 Shell commands to run before the backup.
579 This can for example be used to mount file systems.
583 # To add excluded paths at runtime
584 extraCreateArgs="$extraCreateArgs --exclude /some/path"
588 postInit = lib.mkOption {
589 type = lib.types.lines;
591 Shell commands to run after {command}`borg init`.
596 postCreate = lib.mkOption {
597 type = lib.types.lines;
599 Shell commands to run after {command}`borg create`. The name
600 of the created archive is stored in `$archiveName`.
605 postPrune = lib.mkOption {
606 type = lib.types.lines;
608 Shell commands to run after {command}`borg prune`.
613 postHook = lib.mkOption {
614 type = lib.types.lines;
616 Shell commands to run just before exit. They are executed
617 even if a previous command exits with a non-zero exit code.
618 The latter is available as `$exitStatus`.
623 extraArgs = lib.mkOption {
624 type = with lib.types; coercedTo (listOf str) lib.escapeShellArgs str;
626 Additional arguments for all {command}`borg` calls the
627 service has. Handle with care.
630 example = [ "--remote-path=/path/to/borg" ];
633 extraInitArgs = lib.mkOption {
634 type = with lib.types; coercedTo (listOf str) lib.escapeShellArgs str;
636 Additional arguments for {command}`borg init`.
637 Can also be set at runtime using `$extraInitArgs`.
640 example = [ "--append-only" ];
643 extraCreateArgs = lib.mkOption {
644 type = with lib.types; coercedTo (listOf str) lib.escapeShellArgs str;
646 Additional arguments for {command}`borg create`.
647 Can also be set at runtime using `$extraCreateArgs`.
652 "--checkpoint-interval 600"
656 extraPruneArgs = lib.mkOption {
657 type = with lib.types; coercedTo (listOf str) lib.escapeShellArgs str;
659 Additional arguments for {command}`borg prune`.
660 Can also be set at runtime using `$extraPruneArgs`.
663 example = [ "--save-space" ];
666 extraCompactArgs = lib.mkOption {
667 type = with lib.types; coercedTo (listOf str) lib.escapeShellArgs str;
669 Additional arguments for {command}`borg compact`.
670 Can also be set at runtime using `$extraCompactArgs`.
673 example = [ "--cleanup-commits" ];
680 options.services.borgbackup.repos = lib.mkOption {
682 Serve BorgBackup repositories to given public SSH keys,
683 restricting their access to the repository only.
684 See also the chapter about BorgBackup in the NixOS manual.
685 Also, clients do not need to specify the absolute path when accessing the repository,
686 i.e. `user@machine:.` is enough. (Note colon and dot.)
689 type = lib.types.attrsOf (lib.types.submodule (
692 path = lib.mkOption {
693 type = lib.types.path;
695 Where to store the backups. Note that the directory
696 is created automatically, with correct permissions.
698 default = "/var/lib/borgbackup";
701 user = lib.mkOption {
702 type = lib.types.str;
704 The user {command}`borg serve` is run as.
705 User or group needs write permission
706 for the specified {option}`path`.
711 group = lib.mkOption {
712 type = lib.types.str;
714 The group {command}`borg serve` is run as.
715 User or group needs write permission
716 for the specified {option}`path`.
721 authorizedKeys = lib.mkOption {
722 type = with lib.types; listOf str;
724 Public SSH keys that are given full write access to this repository.
725 You should use a different SSH key for each repository you write to, because
726 the specified keys are restricted to running {command}`borg serve`
727 and can only access this single repository.
732 authorizedKeysAppendOnly = lib.mkOption {
733 type = with lib.types; listOf str;
735 Public SSH keys that can only be used to append new data (archives) to the repository.
736 Note that archives can still be marked as deleted and are subsequently removed from disk
737 upon accessing the repo with full write access, e.g. when pruning.
742 allowSubRepos = lib.mkOption {
743 type = lib.types.bool;
745 Allow clients to create repositories in subdirectories of the
746 specified {option}`path`. These can be accessed using
747 `user@machine:path/to/subrepo`. Note that a
748 {option}`quota` applies to repositories independently.
749 Therefore, if this is enabled, clients can create multiple
750 repositories and upload an arbitrary amount of data.
755 quota = lib.mkOption {
756 # See the definition of parse_file_size() in src/borg/helpers/parseformat.py
757 type = with lib.types; nullOr (strMatching "[[:digit:].]+[KMGTP]?");
759 Storage quota for the repository. This quota is ensured for all
760 sub-repositories if {option}`allowSubRepos` is enabled
761 but not for the overall storage space used.
772 ###### implementation
774 config = lib.mkIf (with config.services.borgbackup; jobs != { } || repos != { })
775 (with config.services.borgbackup; {
777 lib.mapAttrsToList mkPassAssertion jobs
778 ++ lib.mapAttrsToList mkKeysAssertion repos
779 ++ lib.mapAttrsToList mkSourceAssertions jobs
780 ++ lib.mapAttrsToList mkRemovableDeviceAssertions jobs;
782 systemd.tmpfiles.settings = lib.mapAttrs' mkTmpfiles jobs;
785 # A job named "foo" is mapped to systemd.services.borgbackup-job-foo
786 lib.mapAttrs' mkBackupService jobs
787 # A repo named "foo" is mapped to systemd.services.borgbackup-repo-foo
788 // lib.mapAttrs' mkRepoService repos;
790 # A job named "foo" is mapped to systemd.timers.borgbackup-job-foo
791 # only generate the timer if interval (startAt) is set
792 systemd.timers = lib.mapAttrs' mkBackupTimers (lib.filterAttrs (_: cfg: cfg.startAt != []) jobs);
794 users = lib.mkMerge (lib.mapAttrsToList mkUsersConfig repos);
796 environment.systemPackages =
797 [ config.services.borgbackup.package ] ++ (lib.mapAttrsToList mkBorgWrapper jobs);