diff --git a/configuration/common/default.nix b/configuration/common/default.nix
index edb9f73..0e25af1 100644
--- a/configuration/common/default.nix
+++ b/configuration/common/default.nix
@@ -117,6 +117,13 @@
 
   clerie.nixfiles.enable = true;
 
+  clerie.backup = {
+    targets = {
+      cyan.serverName = "cyan.backup.clerie.de";
+      magenta.serverName = "magenta.backup.clerie.de";
+    };
+  };
+
   nixpkgs.overlays = [
     (import ../../pkgs/overlay.nix)
   ];
diff --git a/hosts/minecraft-2/configuration.nix b/hosts/minecraft-2/configuration.nix
index 7f67733..805e088 100644
--- a/hosts/minecraft-2/configuration.nix
+++ b/hosts/minecraft-2/configuration.nix
@@ -51,15 +51,14 @@ in {
     };
   };
 
-  services.restic.backups = {
-    main = {
-      paths = [
-        "/var/src"
-        "/var/lib"
-      ];
-      initialize = true;
-      passwordFile = "/var/src/secrets/restic/main";
-      repositoryFile = "/var/src/secrets/restic/repository-cyan";
+  clerie.backup = {
+    enable = true;
+    jobs = {
+      main = {
+        paths = [
+          "/var/lib"
+        ];
+      };
     };
   };
 
diff --git a/modules/backup/default.nix b/modules/backup/default.nix
new file mode 100644
index 0000000..0b45cac
--- /dev/null
+++ b/modules/backup/default.nix
@@ -0,0 +1,120 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.clerie.backup;
+
+  jobTargetPairs = flatten (
+    mapAttrsToList (jobName: jobOptions:
+      let
+        jobTargets = if jobOptions.targets == null then cfg.targets
+        else
+          filterAttrs (targetName: targetOptions:
+            any (map (jobOptionTarget: jobOptionTarget == targetName) jobOptions.targets)
+          ) cfg.targets;
+      in mapAttrsToList (targetName: targetOptions:
+        {
+          inherit jobName jobOptions targetName targetOptions;
+        }
+      ) jobTargets
+    ) cfg.jobs
+  );
+
+  backupServiceUnits = listToAttrs (map ({jobName, jobOptions, targetName, targetOptions}: let
+    jobPasswordFile = if jobOptions.passwordFile == null then config.age.secrets."clerie-backup-job-${jobName}".path else jobOptions.passwordFile;
+    repoPath = if jobOptions.repoPath == null then "/${config.networking.hostName}/${jobName}" else jobOptions.repoPath;
+    targetPasswordFile = if targetOptions.passwordFile == null then config.age.secrets."clerie-backup-target-${targetName}".path else targetOptions.passwordFile;
+    targetUsername = if targetOptions.username == null then config.networking.hostName else targetOptions.username;
+  in
+    nameValuePair "clerie-backup-${jobName}-${targetName}" {
+      requires = [ "network.target" "local-fs.target" ];
+      path = [ pkgs.restic ];
+
+      serviceConfig = {
+        Type = "oneshot";
+      };
+
+      script = ''
+        set -euo pipefail
+
+        export RESTIC_PASSWORD_FILE=${jobPasswordFile}
+        export RESTIC_REPOSITORY="rest:https://${targetUsername}:$(cat ${targetPasswordFile})@${targetOptions.serverName}${repoPath}"
+
+        restic snapshots || restic init
+
+        restic backup ${escapeShellArgs jobOptions.paths}
+
+        restic check
+      '';
+    }
+  ) jobTargetPairs);
+
+  backupServiceTimers = listToAttrs (map ({jobName, jobOptions, targetName, targetOptions}:
+    nameValuePair "clerie-backup-${jobName}-${targetName}" {
+      wantedBy = [ "timers.target" ];
+      timerConfig = {
+        OnCalendar = "hourly";
+        RandomizedDelaySec = "1h";
+      };
+      after = [ "network-online.target" ];
+    }
+  ) jobTargetPairs);
+
+  targetOptions = { ... }: {
+    options = {
+      passwordFile = mkOption {
+        type = with types; nullOr str;
+        default = null;
+      };
+      username = mkOption {
+        type = with types; nullOr str;
+        default = null;
+      };
+      serverName = mkOption {
+        type = types.str;
+      };
+    };
+  };
+
+  jobOptions = { ... }: {
+    options = {
+      passwordFile = mkOption {
+        type = with types; nullOr str;
+        default = null;
+      };
+      repoPath = mkOption {
+        type = with types; nullOr str;
+        default = null;
+      };
+      targets = mkOption {
+        type = with types; nullOr (listOf str);
+        default = null;
+      };
+      paths = mkOption {
+        type = with types; listOf str;
+      };
+    };
+  };
+in
+
+{
+  options = {
+    clerie.backup = {
+      enable = mkEnableOption "clerie's Backup";
+      targets = mkOption {
+        type = with types; attrsOf (submodule targetOptions);
+        default = {};
+      };
+      jobs = mkOption {
+        type = with types; attrsOf (submodule jobOptions);
+        default = {};
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services = backupServiceUnits;
+    systemd.timers = backupServiceTimers;
+  };
+}
diff --git a/modules/default.nix b/modules/default.nix
index 7366779..374cc53 100644
--- a/modules/default.nix
+++ b/modules/default.nix
@@ -5,6 +5,7 @@
     ./policyrouting
     ./akne
     ./anycast_healthchecker
+    ./backup
     ./clerie-firewall
     ./gre-tunnel
     ./minecraft-server