aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMax Audron <audron@cocaine.farm>2026-01-07 15:28:01 +0100
committerMax Audron <audron@cocaine.farm>2026-01-07 15:28:01 +0100
commit84739ac2345265e518a50bc2e9a239eb442e6e22 (patch)
treee289c856e5465f0c713e97a0ba86e1f734c3484e
parentadd kopia module (diff)
setup backups for mail
-rw-r--r--flake.nix1
-rw-r--r--machines/mail/default.nix85
-rw-r--r--modules/backup/default.nix6
-rw-r--r--modules/backup/maintenance.nix75
-rw-r--r--modules/backup/policy.nix410
-rw-r--r--modules/backup/repositories.nix6
-rw-r--r--modules/backup/snapshot.nix40
-rw-r--r--modules/backup/web.nix4
m---------secrets0
9 files changed, 398 insertions, 229 deletions
diff --git a/flake.nix b/flake.nix
index a7580b8..977bac6 100644
--- a/flake.nix
+++ b/flake.nix
@@ -133,6 +133,7 @@
overlays
common
+ backup
users
wireguard
crypto
diff --git a/machines/mail/default.nix b/machines/mail/default.nix
index 36de86e..a08d419 100644
--- a/machines/mail/default.nix
+++ b/machines/mail/default.nix
@@ -1,6 +1,12 @@
-{ config, lib, pkgs, ... }:
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
-let endpoint = "2a01:4f8:1c1c:3ce7::1";
+let
+ endpoint = "116.203.26.228";
in
{
networking = {
@@ -10,21 +16,36 @@ in
interfaces.eth0 = {
ipv4 = {
addresses = [
- { address="116.203.26.228"; prefixLength=32; }
+ {
+ address = "116.203.26.228";
+ prefixLength = 32;
+ }
];
routes = [
- { address = "172.31.1.1"; prefixLength = 32; }
+ {
+ address = "172.31.1.1";
+ prefixLength = 32;
+ }
];
};
ipv6 = {
addresses = [
- { address="2a01:4f8:1c1c:3ce7::1"; prefixLength=64; }
- { address="fe80::9000:6ff:fe53:14ce"; prefixLength=64; }
+ {
+ address = "2a01:4f8:1c1c:3ce7::1";
+ prefixLength = 64;
+ }
+ {
+ address = "fe80::9000:6ff:fe53:14ce";
+ prefixLength = 64;
+ }
];
-
+
routes = [
- { address = "fe80::1"; prefixLength = 128; }
+ {
+ address = "fe80::1";
+ prefixLength = 128;
+ }
];
};
};
@@ -36,16 +57,54 @@ in
};
};
- pubKey =
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPVwt+/sB77NZnjwqgwtkcqKsIYyMnYh5qlqYoY9dLEd";
+ pubKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPVwt+/sB77NZnjwqgwtkcqKsIYyMnYh5qlqYoY9dLEd";
wireguard = {
enable = true;
inherit endpoint;
- v4 = { address = "10.10.0.6"; };
- v6 = { address = "6"; };
+ v4 = {
+ address = "10.10.0.6";
+ };
+ v6 = {
+ address = "6";
+ };
publicKey = "lk0mN1R5Uf5iwvWe/4mOmrMap7xtsieQaJSHcXQ7+VY=";
- allowedIPs = [];
+ allowedIPs = [ ];
+ };
+
+ services.kopia = {
+ enable = true;
+ instances = {
+ b2 = {
+ enable = true;
+ environmentFile = config.secrets.b2.dest;
+ repository.b2.bucket = "mail-vapor-systems";
+
+ snapshots = {
+ schedule = "daily";
+ paths = [
+ "/var/lib/stalwart-mail"
+ ];
+ };
+
+ policy = [{
+ retention = {
+ keepLatest = 5;
+ keepDaily = 30;
+ keepWeekly = 4;
+ keepMonthly = 3;
+ keepAnnual = 0;
+ };
+ }];
+ };
+ };
+ };
+
+ secrets = {
+ b2 = {
+ source = ../../secrets/backup/mail.vapor.systems.env;
+ dest = "/etc/secrets/b2.env";
+ };
};
deploy = {
diff --git a/modules/backup/default.nix b/modules/backup/default.nix
index ed319f2..9b2ec57 100644
--- a/modules/backup/default.nix
+++ b/modules/backup/default.nix
@@ -20,8 +20,7 @@ let
};
};
mkInstanceServices =
- instances:
- serviceCreator:
+ instances: serviceCreator:
lib.pipe instances [
(lib.attrsets.mapAttrs' serviceCreator)
(lib.recursiveUpdate { })
@@ -33,6 +32,7 @@ in
_module.args.mkInstanceServices = mkInstanceServices;
imports = [
./repositories.nix
+ ./maintenance.nix
./snapshot.nix
./policy.nix
./web.nix
@@ -47,4 +47,6 @@ in
default = { };
};
};
+
+ config.environment.systemPackages = [ pkgs.kopia ];
}
diff --git a/modules/backup/maintenance.nix b/modules/backup/maintenance.nix
new file mode 100644
index 0000000..5f6c971
--- /dev/null
+++ b/modules/backup/maintenance.nix
@@ -0,0 +1,75 @@
+{
+ config,
+ lib,
+ pkgs,
+ mkInstanceServices,
+ ...
+}:
+let
+ instanceType = lib.types.submodule {
+ options = {
+ maintenance = {
+ schedule = lib.mkOption {
+ type = lib.types.str;
+ default = "*-*-* 6:00:00";
+ description = "kopia full maintenance schedule";
+ };
+ };
+ };
+ };
+in
+{
+ options.services.kopia.instances = lib.mkOption {
+ type = lib.types.attrsOf instanceType;
+ };
+
+ config = lib.mkIf config.services.kopia.enable (
+ let
+ mkMaintenanceService =
+ name: instance:
+ lib.attrsets.nameValuePair "kopia-maintenance-${name}" {
+ description = "Kopia ${name} maintenance service";
+ wants = [
+ "kopia-repository-${name}.service"
+ ];
+ after = [ "kopia-repository-${name}.service" ];
+ conflicts = [ "kopia-snapshot-${name}.service" ];
+ script = ''
+ ${pkgs.kopia}/bin/kopia maintenance run --full
+ '';
+ serviceConfig = {
+ Type = "simple";
+ User = "${instance.user}";
+ WorkingDirectory = "~";
+ SetLoginEnvironment = true;
+ # retry on failure
+ Restart = "on-failure";
+ # wait 30 seconds before restarting
+ RestartSec = "30";
+ # lower priority
+ Nice = "-19";
+ IOSchedulingClass = "idle";
+ };
+ unitConfig = {
+ # limit the number of restarts to 5 in 1 day
+ StartLimitInterval = "1d";
+ StartLimitBurst = "5";
+ };
+ };
+ mkMaintenanceTimer =
+ name: instance:
+ lib.attrsets.nameValuePair "kopia-maintenance-${name}" {
+ description = "Kopia ${name} maintenance timer";
+ wantedBy = [ "timers.target" ];
+ timerConfig = {
+ OnCalendar = instance.maintenance.schedule;
+ };
+ };
+ in
+ {
+ # systemd service for repositories open
+ systemd.services = mkInstanceServices config.services.kopia.instances mkMaintenanceService;
+ systemd.timers = mkInstanceServices config.services.kopia.instances mkMaintenanceTimer;
+ }
+ );
+}
diff --git a/modules/backup/policy.nix b/modules/backup/policy.nix
index 5486cac..ab7fb7d 100644
--- a/modules/backup/policy.nix
+++ b/modules/backup/policy.nix
@@ -28,221 +28,232 @@ let
"zstd-fastest"
];
+ policyListType = lib.mkOption {
+ type = lib.types.listOf policySubmodule;
+ default = [];
+ };
+
policyType = lib.mkOption {
- type = lib.types.nullOr (
- lib.types.submodule {
- options = {
- retention = {
- keepLatest = lib.mkOption {
- type = lib.types.int;
- default = 5;
- description = "Number of latest snapshots to keep.";
- };
- keepHourly = lib.mkOption {
- type = lib.types.int;
- default = 48;
- description = "Number of hourly snapshots to keep.";
- };
- keepDaily = lib.mkOption {
- type = lib.types.int;
- default = 7;
- description = "Number of daily snapshots to keep.";
- };
- keepWeekly = lib.mkOption {
- type = lib.types.int;
- default = 4;
- description = "Number of weekly snapshots to keep.";
- };
- keepMonthly = lib.mkOption {
- type = lib.types.int;
- default = 3;
- description = "Number of monthly snapshots to keep.";
- };
- keepAnnual = lib.mkOption {
- type = lib.types.int;
- default = 0;
- description = "Number of yearly snapshots to keep.";
- };
- };
+ type = lib.types.nullOr policySubmodule;
+ default = null;
+ };
- files = {
- ignoreDotFiles = lib.mkOption {
- type = lib.types.listOf lib.types.str;
- default = [
- ".gitignore"
- ".kopiaignore"
- ];
- description = "List of files to source ignore lists from.";
- };
- noParentDotFiles = lib.mkOption {
- type = lib.types.nullOr lib.types.bool;
- default = false;
- description = "Do not use parent ignore dot files.";
- };
- ignoreCacheDirs = lib.mkOption {
- type = lib.types.nullOr lib.types.bool;
- default = false;
- description = "Ignore cache directories.";
- };
- maxFileSize = lib.mkOption {
- type = lib.types.nullOr lib.types.int;
- default = null;
- description = "Maximum file size to include in backup.";
- };
- oneFileSystem = lib.mkOption {
- type = lib.types.nullOr lib.types.bool;
- default = null;
- description = "Stay in parent filesystem when finding files.";
- };
- };
+ policySubmodule = lib.types.submodule {
+ options = {
+ path = lib.mkOption {
+ type = lib.types.nullOr lib.types.path;
+ default = null;
+ description = "Path for which the policy applies";
+ };
- errorHandling = {
- ignoreFileErrors = lib.mkOption {
- type = lib.types.nullOr lib.types.bool;
- default = null;
- description = "Ignore errors reading ignore files.";
- };
- ignoreDirectoryErrors = lib.mkOption {
- type = lib.types.nullOr lib.types.bool;
- default = null;
- description = "Ignore errors reading directories.";
- };
- ignoreUnknownTypes = lib.mkOption {
- type = lib.types.nullOr lib.types.bool;
- default = null;
- description = "Ignore unknown file types.";
- };
- };
+ retention = {
+ keepLatest = lib.mkOption {
+ type = lib.types.int;
+ default = 5;
+ description = "Number of latest snapshots to keep.";
+ };
+ keepHourly = lib.mkOption {
+ type = lib.types.int;
+ default = 48;
+ description = "Number of hourly snapshots to keep.";
+ };
+ keepDaily = lib.mkOption {
+ type = lib.types.int;
+ default = 7;
+ description = "Number of daily snapshots to keep.";
+ };
+ keepWeekly = lib.mkOption {
+ type = lib.types.int;
+ default = 4;
+ description = "Number of weekly snapshots to keep.";
+ };
+ keepMonthly = lib.mkOption {
+ type = lib.types.int;
+ default = 3;
+ description = "Number of monthly snapshots to keep.";
+ };
+ keepAnnual = lib.mkOption {
+ type = lib.types.int;
+ default = 0;
+ description = "Number of yearly snapshots to keep.";
+ };
+ };
- compression = {
- compressorName = lib.mkOption {
- type = compressionType;
- default = "none";
- description = "Name of the compressor to use.";
- };
+ files = {
+ ignoreDotFiles = lib.mkOption {
+ type = lib.types.listOf lib.types.str;
+ default = [
+ ".gitignore"
+ ".kopiaignore"
+ ];
+ description = "List of files to source ignore lists from.";
+ };
+ noParentDotFiles = lib.mkOption {
+ type = lib.types.nullOr lib.types.bool;
+ default = false;
+ description = "Do not use parent ignore dot files.";
+ };
+ ignoreCacheDirs = lib.mkOption {
+ type = lib.types.nullOr lib.types.bool;
+ default = false;
+ description = "Ignore cache directories.";
+ };
+ maxFileSize = lib.mkOption {
+ type = lib.types.nullOr lib.types.int;
+ default = null;
+ description = "Maximum file size to include in backup.";
+ };
+ oneFileSystem = lib.mkOption {
+ type = lib.types.nullOr lib.types.bool;
+ default = null;
+ description = "Stay in parent filesystem when finding files.";
+ };
+ };
- onlyCompress = lib.mkOption {
- type = lib.types.listOf lib.types.str;
- default = [ ];
- description = "List of file extensions to compress.";
- };
+ errorHandling = {
+ ignoreFileErrors = lib.mkOption {
+ type = lib.types.nullOr lib.types.bool;
+ default = null;
+ description = "Ignore errors reading ignore files.";
+ };
+ ignoreDirectoryErrors = lib.mkOption {
+ type = lib.types.nullOr lib.types.bool;
+ default = null;
+ description = "Ignore errors reading directories.";
+ };
+ ignoreUnknownTypes = lib.mkOption {
+ type = lib.types.nullOr lib.types.bool;
+ default = null;
+ description = "Ignore unknown file types.";
+ };
+ };
- noParentOnlyCompress = lib.mkOption {
- type = lib.types.bool;
- default = false;
- description = "Do not use parent only compress list.";
- };
+ compression = {
+ compressorName = lib.mkOption {
+ type = compressionType;
+ default = "none";
+ description = "Name of the compressor to use.";
+ };
- neverCompress = lib.mkOption {
- type = lib.types.listOf lib.types.str;
- default = [ ];
- description = "List of file extensions to never compress.";
- };
+ onlyCompress = lib.mkOption {
+ type = lib.types.listOf lib.types.str;
+ default = [ ];
+ description = "List of file extensions to compress.";
+ };
- noParentNeverCompress = lib.mkOption {
- type = lib.types.bool;
- default = false;
- description = "Do not use parent never compress list.";
- };
+ noParentOnlyCompress = lib.mkOption {
+ type = lib.types.bool;
+ default = false;
+ description = "Do not use parent only compress list.";
+ };
- minSize = lib.mkOption {
- type = lib.types.int;
- default = 0;
- description = "Minimum file size to compress.";
- };
+ neverCompress = lib.mkOption {
+ type = lib.types.listOf lib.types.str;
+ default = [ ];
+ description = "List of file extensions to never compress.";
+ };
- maxSize = lib.mkOption {
- type = lib.types.int;
- default = 0;
- description = "Maximum file size to compress.";
- };
- };
+ noParentNeverCompress = lib.mkOption {
+ type = lib.types.bool;
+ default = false;
+ description = "Do not use parent never compress list.";
+ };
- metadataCompression = {
- compressorName = lib.mkOption {
- type = compressionType;
- default = "zstd-fastest";
- description = "Name of the compressor to use.";
- };
- };
+ minSize = lib.mkOption {
+ type = lib.types.int;
+ default = 0;
+ description = "Minimum file size to compress.";
+ };
- splitter = {
- algorithm = lib.mkOption {
- type = lib.types.nullOr lib.types.str;
- default = null;
- description = "Name of the splitter algorithm to use.";
- };
- };
+ maxSize = lib.mkOption {
+ type = lib.types.int;
+ default = 0;
+ description = "Maximum file size to compress.";
+ };
+ };
- # FIXME: add action definition afterward (maybe implement it during implement at zfs, btrfs snapshot)
+ metadataCompression = {
+ compressorName = lib.mkOption {
+ type = compressionType;
+ default = "zstd-fastest";
+ description = "Name of the compressor to use.";
+ };
+ };
- osSnapshots = {
- volumeShadowCopy = {
- enable = lib.mkOption {
- type = lib.types.nullOr (
- lib.types.enum [
- "never"
- "always"
- "when-available"
- "inherit"
- ]
- );
- default = null;
- description = "Enable volume shadow copy";
- };
- };
- };
+ splitter = {
+ algorithm = lib.mkOption {
+ type = lib.types.nullOr lib.types.str;
+ default = null;
+ description = "Name of the splitter algorithm to use.";
+ };
+ };
+
+ # FIXME: add action definition afterward (maybe implement it during implement at zfs, btrfs snapshot)
- logging = {
- directories = {
- snapshotted = lib.mkOption {
- type = lib.types.nullOr lib.types.int;
- default = null;
- description = "Log detail when a directory is snapshotted";
- };
- ignored = lib.mkOption {
- type = lib.types.nullOr lib.types.int;
- default = null;
- description = "Log detail when a directory is ignored";
- };
- };
+ osSnapshots = {
+ volumeShadowCopy = {
+ enable = lib.mkOption {
+ type = lib.types.nullOr (
+ lib.types.enum [
+ "never"
+ "always"
+ "when-available"
+ "inherit"
+ ]
+ );
+ default = null;
+ description = "Enable volume shadow copy";
+ };
+ };
+ };
- entries = {
- snapshotted = lib.mkOption {
- type = lib.types.nullOr lib.types.int;
- default = null;
- description = "Log detail when an entry is snapshotted";
- };
- ignored = lib.mkOption {
- type = lib.types.nullOr lib.types.int;
- default = null;
- description = "Log detail when an entry is ignored";
- };
- };
+ logging = {
+ directories = {
+ snapshotted = lib.mkOption {
+ type = lib.types.nullOr lib.types.int;
+ default = null;
+ description = "Log detail when a directory is snapshotted";
};
+ ignored = lib.mkOption {
+ type = lib.types.nullOr lib.types.int;
+ default = null;
+ description = "Log detail when a directory is ignored";
+ };
+ };
- upload = {
- # maxParallelSnapshots - GUI only
- maxParallelFileReads = lib.mkOption {
- type = lib.types.nullOr lib.types.int;
- default = null;
- description = "Maximum number of parallel file reads(GUI Only)";
- };
- parallelUploadAboveSize = lib.mkOption {
- type = lib.types.nullOr lib.types.int;
- default = null;
- description = "Use parallel uploads above size(GUI Only)";
- };
+ entries = {
+ snapshotted = lib.mkOption {
+ type = lib.types.nullOr lib.types.int;
+ default = null;
+ description = "Log detail when an entry is snapshotted";
+ };
+ ignored = lib.mkOption {
+ type = lib.types.nullOr lib.types.int;
+ default = null;
+ description = "Log detail when an entry is ignored";
};
};
- }
- );
- default = null;
+ };
+
+ upload = {
+ # maxParallelSnapshots - GUI only
+ maxParallelFileReads = lib.mkOption {
+ type = lib.types.nullOr lib.types.int;
+ default = null;
+ description = "Maximum number of parallel file reads(GUI Only)";
+ };
+ parallelUploadAboveSize = lib.mkOption {
+ type = lib.types.nullOr lib.types.int;
+ default = null;
+ description = "Use parallel uploads above size(GUI Only)";
+ };
+ };
+ };
};
instanceType = lib.types.submodule {
options = {
- policy = policyType;
+ policy = policyListType;
};
};
@@ -252,18 +263,24 @@ let
# generate policy name for policy file generation
mkPolicyName =
user: hostname: path:
- "${user}@${hostname}${if path != "" then ":${path}" else ""}";
+ "${user}@${hostname}${if path != null then ":${path}" else ""}";
mkPolicyFile = policy: (jsonFormat.generate "kopia-policy.json" policy);
mkInstancePolicyService =
name: instance:
let
- policyName = mkPolicyName instance.user config.networking.hostName instance.path;
- policyFile = mkPolicyFile (
- {
- "${policyName}" = instance.policy;
+ policies = lib.lists.foldr (
+ policy: a:
+ a
+ // {
+ "${mkPolicyName instance.user config.networking.hostName policy.path}" = lib.attrsets.filterAttrs (
+ n: v: n != "path"
+ ) policy;
}
+ ) { } instance.policy;
+ policyFile = mkPolicyFile (
+ policies
// lib.optionalAttrs (config.services.kopia.globalPolicy != null) {
"(global)" = config.services.kopia.globalPolicy;
}
@@ -275,6 +292,7 @@ let
wantedBy = [ "kopia-snapshot-${name}.service" ];
after = [ "kopia-repository-${name}.service" ];
before = [ "kopia-snapshot-${name}.service" ];
+ restartTriggers = [ policyFile ];
script = ''
${pkgs.kopia}/bin/kopia policy import --from-file=${policyFile}
'';
diff --git a/modules/backup/repositories.nix b/modules/backup/repositories.nix
index 9ee2b3e..f900b0f 100644
--- a/modules/backup/repositories.nix
+++ b/modules/backup/repositories.nix
@@ -79,7 +79,7 @@ in
]
++ (lib.optional (instance.repository.s3.disableTLS) "--disable-tls")
else if lib.hasAttr "b2" instance.repository then
- [ "--bucket" instance.repository.s3.bucket ]
+ [ "--bucket" instance.repository.b2.bucket ]
else
throw "Unsupported repository type for Kopia instance ${name}"
);
@@ -122,8 +122,8 @@ in
};
};
- mkS3Repository = mkTemplateRepository "S3" [ "AWS_ACCESS_KEY_ID" "AWS_SECRET_ACCESS_KEY" ];
- mkB2Repository = mkTemplateRepository "B2" [ "B2_KEY_ID" "B2_KEY" ];
+ mkS3Repository = mkTemplateRepository "s3" [ "AWS_ACCESS_KEY_ID" "AWS_SECRET_ACCESS_KEY" ];
+ mkB2Repository = mkTemplateRepository "b2" [ "B2_KEY_ID" "B2_KEY" ];
dispatch = {
s3 = mkS3Repository;
diff --git a/modules/backup/snapshot.nix b/modules/backup/snapshot.nix
index 91076cf..a1fba73 100644
--- a/modules/backup/snapshot.nix
+++ b/modules/backup/snapshot.nix
@@ -6,17 +6,25 @@
...
}:
let
- instanceType = lib.types.submodule {
+ snapshotType = lib.types.submodule {
options = {
- path = lib.mkOption {
- type = lib.types.str;
- default = "/persistent";
- description = "snapshoted path for kopia instance.";
+ paths = lib.mkOption {
+ type = lib.types.listOf lib.types.str;
+ default = [ ];
+ description = "snapshoted paths";
};
schedule = lib.mkOption {
type = lib.types.str;
- default = "daily";
- description = "Snapshot schedule for the Kopia instance.";
+ default = "*-*-* 4:00:00";
+ description = "Snapshot schedule";
+ };
+ };
+ };
+
+ instanceType = lib.types.submodule {
+ options = {
+ snapshots = lib.mkOption {
+ type = snapshotType;
};
};
};
@@ -37,9 +45,11 @@ in
"kopia-repository-${name}.service"
];
after = [ "kopia-repository-${name}.service" ];
- script = ''
- ${pkgs.kopia}/bin/kopia snapshot create ${instance.path} --description "Snapshot for ${name}"
- '';
+ script = lib.strings.concatLines (
+ lib.lists.forEach instance.snapshots.paths (x: ''
+ ${pkgs.kopia}/bin/kopia snapshot create ${x} --description "Snapshot for ${name}"
+ '')
+ );
serviceConfig = {
Type = "simple";
User = "${instance.user}";
@@ -49,13 +59,15 @@ in
Restart = "on-failure";
# wait 30 seconds before restarting
RestartSec = "30";
- # limit the number of restarts to 5 in 1 day
- StartLimitInterval = "1d";
- StartLimitBurst = "5";
# lower priority
Nice = "-19";
IOSchedulingClass = "idle";
};
+ unitConfig = {
+ # limit the number of restarts to 5 in 1 day
+ StartLimitInterval = "1d";
+ StartLimitBurst = "5";
+ };
};
mkSnapshotTimer =
name: instance:
@@ -63,7 +75,7 @@ in
description = "Kopia S3 snapshot timer";
wantedBy = [ "timers.target" ];
timerConfig = {
- OnCalendar = instance.schedule;
+ OnCalendar = instance.snapshots.schedule;
};
};
in
diff --git a/modules/backup/web.nix b/modules/backup/web.nix
index 9937315..b89d0a9 100644
--- a/modules/backup/web.nix
+++ b/modules/backup/web.nix
@@ -64,8 +64,10 @@ in
Restart = "on-failure";
# wait 30 seconds before restarting
RestartSec = "30";
+ };
+ unitConfig = {
# limit the number of restarts to 5 in 1 day
- StartLimitIntervalSec = "1d";
+ StartLimitInterval = "1d";
StartLimitBurst = "5";
};
};
diff --git a/secrets b/secrets
-Subproject c3829d52e773536493c6d12136893c842d13fdd
+Subproject ba100958ff3b2295e812a2bd1e8f14e3db0d06c