1 { config, lib, pkgs, ... }:
5 cfg = config.services.duplicity;
7 stateDirectory = "/var/lib/duplicity";
10 if hasPrefix "file://" cfg.targetUrl
11 then removePrefix "file://" cfg.targetUrl else null;
15 options.services.duplicity = {
16 enable = mkEnableOption (lib.mdDoc "backups with duplicity");
21 description = lib.mdDoc ''
22 Root directory to backup.
27 type = types.listOf types.str;
29 example = [ "/home" ];
30 description = lib.mdDoc ''
31 List of paths to include into the backups. See the FILE SELECTION
32 section in {manpage}`duplicity(1)` for details on the syntax.
37 type = types.listOf types.str;
39 description = lib.mdDoc ''
40 List of paths to exclude from backups. See the FILE SELECTION section in
41 {manpage}`duplicity(1)` for details on the syntax.
45 targetUrl = mkOption {
47 example = "s3://host:port/prefix";
48 description = lib.mdDoc ''
49 Target url to backup to. See the URL FORMAT section in
50 {manpage}`duplicity(1)` for supported urls.
54 secretFile = mkOption {
55 type = types.nullOr types.path;
57 description = lib.mdDoc ''
58 Path of a file containing secrets (gpg passphrase, access key...) in
59 the format of EnvironmentFile as described by
60 {manpage}`systemd.exec(5)`. For example:
63 AWS_ACCESS_KEY_ID=«...»
64 AWS_SECRET_ACCESS_KEY=«...»
69 frequency = mkOption {
70 type = types.nullOr types.str;
72 description = lib.mdDoc ''
73 Run duplicity with the given frequency (see
74 {manpage}`systemd.time(7)` for the format).
75 If null, do not run automatically.
79 extraFlags = mkOption {
80 type = types.listOf types.str;
82 example = [ "--backend-retry-delay" "100" ];
83 description = lib.mdDoc ''
84 Extra command-line flags passed to duplicity. See
85 {manpage}`duplicity(1)`.
89 fullIfOlderThan = mkOption {
93 description = lib.mdDoc ''
94 If `"never"` (the default) always do incremental
95 backups (the first backup will be a full backup, of course). If
96 `"always"` always do full backups. Otherwise, this
97 must be a string representing a duration. Full backups will be made
98 when the latest full backup is older than this duration. If this is not
99 the case, an incremental backup is performed.
105 type = types.nullOr types.str;
108 description = lib.mdDoc ''
109 If non-null, delete all backup sets older than the given time. Old backup sets
110 will not be deleted if backup sets newer than time depend on them.
114 type = types.nullOr types.int;
117 description = lib.mdDoc ''
118 If non-null, delete all backups sets that are older than the count:th last full
119 backup (in other words, keep the last count full backups and
120 associated incremental sets).
124 type = types.nullOr types.int;
127 description = lib.mdDoc ''
128 If non-null, delete incremental sets of all backups sets that are
129 older than the count:th last full backup (in other words, keep only
130 old full backups and not their increments).
136 config = mkIf cfg.enable {
138 services.duplicity = {
139 description = "backup files with duplicity";
141 environment.HOME = stateDirectory;
145 target = escapeShellArg cfg.targetUrl;
146 extra = escapeShellArgs ([ "--archive-dir" stateDirectory ] ++ cfg.extraFlags);
147 dup = "${pkgs.duplicity}/bin/duplicity";
151 ${dup} cleanup ${target} --force ${extra}
152 ${lib.optionalString (cfg.cleanup.maxAge != null) "${dup} remove-older-than ${lib.escapeShellArg cfg.cleanup.maxAge} ${target} --force ${extra}"}
153 ${lib.optionalString (cfg.cleanup.maxFull != null) "${dup} remove-all-but-n-full ${toString cfg.cleanup.maxFull} ${target} --force ${extra}"}
154 ${lib.optionalString (cfg.cleanup.maxIncr != null) "${dup} remove-all-inc-of-but-n-full ${toString cfg.cleanup.maxIncr} ${target} --force ${extra}"}
155 exec ${dup} ${if cfg.fullIfOlderThan == "always" then "full" else "incr"} ${lib.escapeShellArgs (
156 [ cfg.root cfg.targetUrl ]
157 ++ concatMap (p: [ "--include" p ]) cfg.include
158 ++ concatMap (p: [ "--exclude" p ]) cfg.exclude
159 ++ (lib.optionals (cfg.fullIfOlderThan != "never" && cfg.fullIfOlderThan != "always") [ "--full-if-older-than" cfg.fullIfOlderThan ])
164 ProtectSystem = "strict";
165 ProtectHome = "read-only";
166 StateDirectory = baseNameOf stateDirectory;
167 } // optionalAttrs (localTarget != null) {
168 ReadWritePaths = localTarget;
169 } // optionalAttrs (cfg.secretFile != null) {
170 EnvironmentFile = cfg.secretFile;
172 } // optionalAttrs (cfg.frequency != null) {
173 startAt = cfg.frequency;
176 tmpfiles.rules = optional (localTarget != null) "d ${localTarget} 0700 root root -";
179 assertions = singleton {
180 # Duplicity will fail if the last file selection option is an include. It
181 # is not always possible to detect but this simple case can be caught.
182 assertion = cfg.include != [ ] -> cfg.exclude != [ ] || cfg.extraFlags != [ ];
184 Duplicity will fail if you only specify included paths ("Because the
185 default is to include all files, the expression is redundant. Exiting
186 because this probably isn't what you meant.")