1 { config, lib, pkgs, ... }:
8 builtins.substring 0 1 x == "/" # absolute path
9 || builtins.substring 0 1 x == "." # relative path
10 || builtins.match "[.*:.*]" == null; # not machine:path
13 # Write each exclude pattern to a new line
14 pkgs.writeText "excludefile" (concatStringsSep "\n" cfg.exclude);
17 # If cfg.prune.keep e.g. has a yearly attribute,
18 # its content is passed on as --keep-yearly
20 (mapAttrsToList (x: y: "--keep-${x}=${toString y}") cfg.prune.keep);
22 mkBackupScript = cfg: ''
31 archiveName="${if cfg.archiveBaseName == null then "" else cfg.archiveBaseName + "-"}$(date ${cfg.dateFormat})"
32 archiveSuffix="${optionalString cfg.appendFailedSuffix ".failed"}"
34 '' + optionalString cfg.doInit ''
35 # Run borg init if the repo doesn't exist yet
36 if ! borg list $extraArgs > /dev/null; then
37 borg init $extraArgs \
38 --encryption ${cfg.encryption.mode} \
45 ${optionalString (cfg.dumpCommand != null) ''${escapeShellArg cfg.dumpCommand} | \''}
46 borg create $extraArgs \
47 --compression ${cfg.compression} \
48 --exclude-from ${mkExcludeFile cfg} \
50 "::$archiveName$archiveSuffix" \
51 ${if cfg.paths == null then "-" else escapeShellArgs cfg.paths}
53 '' + optionalString cfg.appendFailedSuffix ''
54 borg rename $extraArgs \
55 "::$archiveName$archiveSuffix" "$archiveName"
58 '' + optionalString (cfg.prune.keep != { }) ''
59 borg prune $extraArgs \
61 ${optionalString (cfg.prune.prefix != null) "--prefix ${escapeShellArg cfg.prune.prefix} \\"}
66 mkPassEnv = cfg: with cfg.encryption;
67 if passCommand != null then
68 { BORG_PASSCOMMAND = passCommand; }
69 else if passphrase != null then
70 { BORG_PASSPHRASE = passphrase; }
73 mkBackupService = name: cfg:
75 userHome = config.users.users.${cfg.user}.home;
76 in nameValuePair "borgbackup-job-${name}" {
77 description = "BorgBackup job ${name}";
81 script = mkBackupScript cfg;
85 # Only run when no other process is using CPU or disk
86 CPUSchedulingPolicy = "idle";
87 IOSchedulingClass = "idle";
88 ProtectSystem = "strict";
90 [ "${userHome}/.config/borg" "${userHome}/.cache/borg" ]
92 # Borg needs write access to repo if it is not remote
93 ++ optional (isLocalPath cfg.repo) cfg.repo;
94 PrivateTmp = cfg.privateTmp;
98 inherit (cfg) extraArgs extraInitArgs extraCreateArgs extraPruneArgs;
99 } // (mkPassEnv cfg) // cfg.environment;
102 mkBackupTimers = name: cfg:
103 nameValuePair "borgbackup-job-${name}" {
104 description = "BorgBackup job ${name} timer";
105 wantedBy = [ "timers.target" ];
107 Persistent = cfg.persistentTimer;
108 OnCalendar = cfg.startAt;
110 # if remote-backup wait for network
111 after = optional (cfg.persistentTimer && !isLocalPath cfg.repo) "network-online.target";
114 # utility function around makeWrapper
116 original, name, set ? {}
118 pkgs.runCommand "${name}-wrapper" {
119 nativeBuildInputs = [ pkgs.makeWrapper ];
121 makeWrapper "${original}" "$out/bin/${name}" \
122 ${concatStringsSep " \\\n " (mapAttrsToList (name: value: ''--set ${name} "${value}"'') set)}
125 mkBorgWrapper = name: cfg: mkWrapperDrv {
126 original = "${pkgs.borgbackup}/bin/borg";
127 name = "borg-job-${name}";
128 set = { BORG_REPO = cfg.repo; } // (mkPassEnv cfg) // cfg.environment;
131 # Paths listed in ReadWritePaths must exist before service is started
132 mkActivationScript = name: cfg:
134 install = "install -o ${cfg.user} -g ${cfg.group}";
136 nameValuePair "borgbackup-job-${name}" (stringAfter [ "users" ] (''
137 # Ensure that the home directory already exists
138 # We can't assert createHome == true because that's not the case for root
139 cd "${config.users.users.${cfg.user}.home}"
140 ${install} -d .config/borg
141 ${install} -d .cache/borg
142 '' + optionalString (isLocalPath cfg.repo && !cfg.removableDevice) ''
143 ${install} -d ${escapeShellArg cfg.repo}
146 mkPassAssertion = name: cfg: {
147 assertion = with cfg.encryption;
148 mode != "none" -> passCommand != null || passphrase != null;
150 "passCommand or passphrase has to be specified because"
151 + '' borgbackup.jobs.${name}.encryption != "none"'';
154 mkRepoService = name: cfg:
155 nameValuePair "borgbackup-repo-${name}" {
156 description = "Create BorgBackup repository ${name} directory";
158 mkdir -p ${escapeShellArg cfg.path}
159 chown ${cfg.user}:${cfg.group} ${escapeShellArg cfg.path}
162 # The service's only task is to ensure that the specified path exists
165 wantedBy = [ "multi-user.target" ];
168 mkAuthorizedKey = cfg: appendOnly: key:
170 # Because of the following line, clients do not need to specify an absolute repo path
171 cdCommand = "cd ${escapeShellArg cfg.path}";
172 restrictedArg = "--restrict-to-${if cfg.allowSubRepos then "path" else "repository"} .";
173 appendOnlyArg = optionalString appendOnly "--append-only";
174 quotaArg = optionalString (cfg.quota != null) "--storage-quota ${cfg.quota}";
175 serveCommand = "borg serve ${restrictedArg} ${appendOnlyArg} ${quotaArg}";
177 ''command="${cdCommand} && ${serveCommand}",restrict ${key}'';
179 mkUsersConfig = name: cfg: {
180 users.${cfg.user} = {
181 openssh.authorizedKeys.keys =
182 (map (mkAuthorizedKey cfg false) cfg.authorizedKeys
183 ++ map (mkAuthorizedKey cfg true) cfg.authorizedKeysAppendOnly);
184 useDefaultShell = true;
188 groups.${cfg.group} = { };
191 mkKeysAssertion = name: cfg: {
192 assertion = cfg.authorizedKeys != [ ] || cfg.authorizedKeysAppendOnly != [ ];
194 "borgbackup.repos.${name} does not make sense"
195 + " without at least one public key";
198 mkSourceAssertions = name: cfg: {
199 assertion = count isNull [ cfg.dumpCommand cfg.paths ] == 1;
201 Exactly one of borgbackup.jobs.${name}.paths or borgbackup.jobs.${name}.dumpCommand
206 mkRemovableDeviceAssertions = name: cfg: {
207 assertion = !(isLocalPath cfg.repo) -> !cfg.removableDevice;
209 borgbackup.repos.${name}: repo isn't a local path, thus it can't be a removable device!
214 meta.maintainers = with maintainers; [ dotlambda ];
215 meta.doc = ./borgbackup.xml;
219 options.services.borgbackup.jobs = mkOption {
220 description = lib.mdDoc ''
221 Deduplicating backups using BorgBackup.
222 Adding a job will cause a borg-job-NAME wrapper to be added
223 to your system path, so that you can perform maintenance easily.
224 See also the chapter about BorgBackup in the NixOS manual.
227 example = literalExpression ''
228 { # for a local backup
231 exclude = [ "/nix" ];
232 repo = "/path/to/local/repo";
235 passphrase = "secret";
237 compression = "auto,lzma";
241 { # Root backing each day up to a remote backup server. We assume that you have
242 # * created a password less key: ssh-keygen -N "" -t ed25519 -f /path/to/ssh_key
243 # best practices are: use -t ed25519, /path/to = /run/keys
244 # * the passphrase is in the file /run/keys/borgbackup_passphrase
245 # * you have initialized the repository manually
246 paths = [ "/etc" "/home" ];
247 exclude = [ "/nix" "'**/.cache'" ];
249 repo = "user3@arep.repo.borgbase.com:repo";
251 mode = "repokey-blake2";
252 passCommand = "cat /path/to/passphrase";
254 environment = { BORG_RSH = "ssh -i /path/to/ssh_key"; };
255 compression = "auto,lzma";
259 type = types.attrsOf (types.submodule (let globalConfig = config; in
260 { name, config, ... }: {
264 type = with types; nullOr (coercedTo str lib.singleton (listOf str));
266 description = lib.mdDoc ''
268 Mutually exclusive with {option}`dumpCommand`.
270 example = "/home/user";
273 dumpCommand = mkOption {
274 type = with types; nullOr path;
276 description = lib.mdDoc ''
277 Backup the stdout of this program instead of filesystem paths.
278 Mutually exclusive with {option}`paths`.
280 example = "/path/to/createZFSsend.sh";
285 description = lib.mdDoc "Remote or local repository to back up to.";
286 example = "user@machine:/path/to/repo";
289 removableDevice = mkOption {
292 description = lib.mdDoc "Whether the repo (which must be local) is a removable device.";
295 archiveBaseName = mkOption {
296 type = types.nullOr (types.strMatching "[^/{}]+");
297 default = "${globalConfig.networking.hostName}-${name}";
298 defaultText = literalExpression ''"''${config.networking.hostName}-<name>"'';
299 description = lib.mdDoc ''
300 How to name the created archives. A timestamp, whose format is
301 determined by {option}`dateFormat`, will be appended. The full
302 name can be modified at runtime (`$archiveName`).
303 Placeholders like `{hostname}` must not be used.
304 Use `null` for no base name.
308 dateFormat = mkOption {
310 description = lib.mdDoc ''
311 Arguments passed to {command}`date`
312 to create a timestamp suffix for the archive name.
314 default = "+%Y-%m-%dT%H:%M:%S";
319 type = with types; either str (listOf str);
321 description = lib.mdDoc ''
322 When or how often the backup should run.
323 Must be in the format described in
324 {manpage}`systemd.time(7)`.
325 If you do not want the backup to start
326 automatically, use `[ ]`.
327 It will generate a systemd service borgbackup-job-NAME.
328 You may trigger it manually via systemctl restart borgbackup-job-NAME.
332 persistentTimer = mkOption {
336 description = lib.mdDoc ''
337 Set the `persistentTimer` option for the
338 {manpage}`systemd.timer(5)`
339 which triggers the backup immediately if the last trigger
340 was missed (e.g. if the system was powered down).
346 description = lib.mdDoc ''
347 The user {command}`borg` is run as.
348 User or group need read permission
349 for the specified {option}`paths`.
356 description = lib.mdDoc ''
357 The group borg is run as. User or group needs read permission
358 for the specified {option}`paths`.
363 encryption.mode = mkOption {
366 "repokey-blake2" "keyfile-blake2"
367 "authenticated" "authenticated-blake2"
370 description = lib.mdDoc ''
371 Encryption mode to use. Setting a mode
372 other than `"none"` requires
373 you to specify a {option}`passCommand`
374 or a {option}`passphrase`.
376 example = "repokey-blake2";
379 encryption.passCommand = mkOption {
380 type = with types; nullOr str;
381 description = lib.mdDoc ''
382 A command which prints the passphrase to stdout.
383 Mutually exclusive with {option}`passphrase`.
386 example = "cat /path/to/passphrase_file";
389 encryption.passphrase = mkOption {
390 type = with types; nullOr str;
391 description = lib.mdDoc ''
392 The passphrase the backups are encrypted with.
393 Mutually exclusive with {option}`passCommand`.
394 If you do not want the passphrase to be stored in the
395 world-readable Nix store, use {option}`passCommand`.
400 compression = mkOption {
401 # "auto" is optional,
402 # compression mode must be given,
403 # compression level is optional
404 type = types.strMatching "none|(auto,)?(lz4|zstd|zlib|lzma)(,[[:digit:]]{1,2})?";
405 description = lib.mdDoc ''
406 Compression method to use. Refer to
407 {command}`borg help compression`
408 for all available options.
411 example = "auto,lzma";
415 type = with types; listOf str;
416 description = lib.mdDoc ''
417 Exclude paths matching any of the given patterns. See
418 {command}`borg help patterns` for pattern syntax.
427 readWritePaths = mkOption {
428 type = with types; listOf path;
429 description = lib.mdDoc ''
430 By default, borg cannot write anywhere on the system but
431 `$HOME/.config/borg` and `$HOME/.cache/borg`.
432 If, for example, your preHook script needs to dump files
433 somewhere, put those directories here.
437 "/var/backup/mysqldump"
441 privateTmp = mkOption {
443 description = lib.mdDoc ''
444 Set the `PrivateTmp` option for
445 the systemd-service. Set to false if you need sockets
446 or other files from global /tmp.
453 description = lib.mdDoc ''
454 Run {command}`borg init` if the
455 specified {option}`repo` does not exist.
456 You should set this to `false`
457 if the repository is located on an external drive
458 that might not always be mounted.
463 appendFailedSuffix = mkOption {
465 description = lib.mdDoc ''
466 Append a `.failed` suffix
467 to the archive name, which is only removed if
468 {command}`borg create` has a zero exit status.
473 prune.keep = mkOption {
474 # Specifying e.g. `prune.keep.yearly = -1`
475 # means there is no limit of yearly archives to keep
476 # The regex is for use with e.g. --keep-within 1y
477 type = with types; attrsOf (either int (strMatching "[[:digit:]]+[Hdwmy]"));
478 description = lib.mdDoc ''
479 Prune a repository by deleting all archives not matching any of the
480 specified retention options. See {command}`borg help prune`
481 for the available options.
484 example = literalExpression ''
486 within = "1d"; # Keep all archives from the last day
489 monthly = -1; # Keep at least one archive for each month
494 prune.prefix = mkOption {
495 type = types.nullOr (types.str);
496 description = lib.mdDoc ''
497 Only consider archive names starting with this prefix for pruning.
498 By default, only archives created by this job are considered.
499 Use `""` or `null` to consider all archives.
501 default = config.archiveBaseName;
502 defaultText = literalExpression "archiveBaseName";
505 environment = mkOption {
506 type = with types; attrsOf str;
507 description = lib.mdDoc ''
508 Environment variables passed to the backup script.
509 You can for example specify which SSH key to use.
512 example = { BORG_RSH = "ssh -i /path/to/key"; };
517 description = lib.mdDoc ''
518 Shell commands to run before the backup.
519 This can for example be used to mount file systems.
523 # To add excluded paths at runtime
524 extraCreateArgs="$extraCreateArgs --exclude /some/path"
528 postInit = mkOption {
530 description = lib.mdDoc ''
531 Shell commands to run after {command}`borg init`.
536 postCreate = mkOption {
538 description = lib.mdDoc ''
539 Shell commands to run after {command}`borg create`. The name
540 of the created archive is stored in `$archiveName`.
545 postPrune = mkOption {
547 description = lib.mdDoc ''
548 Shell commands to run after {command}`borg prune`.
553 postHook = mkOption {
555 description = lib.mdDoc ''
556 Shell commands to run just before exit. They are executed
557 even if a previous command exits with a non-zero exit code.
558 The latter is available as `$exitStatus`.
563 extraArgs = mkOption {
565 description = lib.mdDoc ''
566 Additional arguments for all {command}`borg` calls the
567 service has. Handle with care.
570 example = "--remote-path=/path/to/borg";
573 extraInitArgs = mkOption {
575 description = lib.mdDoc ''
576 Additional arguments for {command}`borg init`.
577 Can also be set at runtime using `$extraInitArgs`.
580 example = "--append-only";
583 extraCreateArgs = mkOption {
585 description = lib.mdDoc ''
586 Additional arguments for {command}`borg create`.
587 Can also be set at runtime using `$extraCreateArgs`.
590 example = "--stats --checkpoint-interval 600";
593 extraPruneArgs = mkOption {
595 description = lib.mdDoc ''
596 Additional arguments for {command}`borg prune`.
597 Can also be set at runtime using `$extraPruneArgs`.
600 example = "--save-space";
608 options.services.borgbackup.repos = mkOption {
609 description = lib.mdDoc ''
610 Serve BorgBackup repositories to given public SSH keys,
611 restricting their access to the repository only.
612 See also the chapter about BorgBackup in the NixOS manual.
613 Also, clients do not need to specify the absolute path when accessing the repository,
614 i.e. `user@machine:.` is enough. (Note colon and dot.)
617 type = types.attrsOf (types.submodule (
622 description = lib.mdDoc ''
623 Where to store the backups. Note that the directory
624 is created automatically, with correct permissions.
626 default = "/var/lib/borgbackup";
631 description = lib.mdDoc ''
632 The user {command}`borg serve` is run as.
633 User or group needs write permission
634 for the specified {option}`path`.
641 description = lib.mdDoc ''
642 The group {command}`borg serve` is run as.
643 User or group needs write permission
644 for the specified {option}`path`.
649 authorizedKeys = mkOption {
650 type = with types; listOf str;
651 description = lib.mdDoc ''
652 Public SSH keys that are given full write access to this repository.
653 You should use a different SSH key for each repository you write to, because
654 the specified keys are restricted to running {command}`borg serve`
655 and can only access this single repository.
660 authorizedKeysAppendOnly = mkOption {
661 type = with types; listOf str;
662 description = lib.mdDoc ''
663 Public SSH keys that can only be used to append new data (archives) to the repository.
664 Note that archives can still be marked as deleted and are subsequently removed from disk
665 upon accessing the repo with full write access, e.g. when pruning.
670 allowSubRepos = mkOption {
672 description = lib.mdDoc ''
673 Allow clients to create repositories in subdirectories of the
674 specified {option}`path`. These can be accessed using
675 `user@machine:path/to/subrepo`. Note that a
676 {option}`quota` applies to repositories independently.
677 Therefore, if this is enabled, clients can create multiple
678 repositories and upload an arbitrary amount of data.
684 # See the definition of parse_file_size() in src/borg/helpers/parseformat.py
685 type = with types; nullOr (strMatching "[[:digit:].]+[KMGTP]?");
686 description = lib.mdDoc ''
687 Storage quota for the repository. This quota is ensured for all
688 sub-repositories if {option}`allowSubRepos` is enabled
689 but not for the overall storage space used.
700 ###### implementation
702 config = mkIf (with config.services.borgbackup; jobs != { } || repos != { })
703 (with config.services.borgbackup; {
705 mapAttrsToList mkPassAssertion jobs
706 ++ mapAttrsToList mkKeysAssertion repos
707 ++ mapAttrsToList mkSourceAssertions jobs
708 ++ mapAttrsToList mkRemovableDeviceAssertions jobs;
710 system.activationScripts = mapAttrs' mkActivationScript jobs;
713 # A job named "foo" is mapped to systemd.services.borgbackup-job-foo
714 mapAttrs' mkBackupService jobs
715 # A repo named "foo" is mapped to systemd.services.borgbackup-repo-foo
716 // mapAttrs' mkRepoService repos;
718 # A job named "foo" is mapped to systemd.timers.borgbackup-job-foo
719 # only generate the timer if interval (startAt) is set
720 systemd.timers = mapAttrs' mkBackupTimers (filterAttrs (_: cfg: cfg.startAt != []) jobs);
722 users = mkMerge (mapAttrsToList mkUsersConfig repos);
724 environment.systemPackages = with pkgs; [ borgbackup ] ++ (mapAttrsToList mkBorgWrapper jobs);