diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 1f2fbb7d85c9..4335a4b3eece 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -326,6 +326,7 @@
./services/misc/ripple-data-api.nix
./services/misc/rogue.nix
./services/misc/siproxd.nix
+ ./services/misc/snapper.nix
./services/misc/sonarr.nix
./services/misc/spice-vdagentd.nix
./services/misc/ssm-agent.nix
diff --git a/nixos/modules/services/misc/snapper.nix b/nixos/modules/services/misc/snapper.nix
new file mode 100644
index 000000000000..62b344d11b06
--- /dev/null
+++ b/nixos/modules/services/misc/snapper.nix
@@ -0,0 +1,152 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+ cfg = config.services.snapper;
+in
+
+{
+ options.services.snapper = {
+
+ snapshotInterval = mkOption {
+ type = types.str;
+ default = "hourly";
+ description = ''
+ Snapshot interval.
+
+ The format is described in
+ systemd.time
+ 7.
+ '';
+ };
+
+ cleanupInterval = mkOption {
+ type = types.str;
+ default = "1d";
+ description = ''
+ Cleanup interval.
+
+ The format is described in
+ systemd.time
+ 7.
+ '';
+ };
+
+ filters = mkOption {
+ type = types.nullOr types.lines;
+ default = null;
+ description = ''
+ Global display difference filter. See man:snapper(8) for more details.
+ '';
+ };
+
+ configs = mkOption {
+ default = { };
+ example = literalExample {
+ "home" = {
+ subvolume = "/home";
+ extraConfig = ''
+ ALLOW_USERS="alice"
+ '';
+ };
+ };
+
+ description = ''
+ Subvolume configuration
+ '';
+
+ type = types.attrsOf (types.submodule {
+ options = {
+ subvolume = mkOption {
+ type = types.path;
+ description = ''
+ Path of the subvolume or mount point.
+ This path is a subvolume and has to contain a subvolume named
+ .snapshots.
+ See also man:snapper(8) section PERMISSIONS.
+ '';
+ };
+
+ fstype = mkOption {
+ type = types.enum [ "btrfs" ];
+ default = "btrfs";
+ description = ''
+ Filesystem type. Only btrfs is stable and tested.
+ '';
+ };
+
+ extraConfig = mkOption {
+ type = types.lines;
+ default = "";
+ description = ''
+ Additional configuration next to SUBVOLUME and FSTYPE.
+ See man:snapper-configs(5).
+ '';
+ };
+ };
+ });
+ };
+ };
+
+ config = mkIf (cfg.configs != {}) (let
+ documentation = [ "man:snapper(8)" "man:snapper-configs(5)" ];
+ in {
+
+ environment = {
+
+ systemPackages = [ pkgs.snapper ];
+
+ # Note: snapper/config-templates/default is only needed for create-config
+ # which is not the NixOS way to configure.
+ etc = {
+
+ "sysconfig/snapper".text = ''
+ SNAPPER_CONFIGS="${lib.concatStringsSep " " (builtins.attrNames cfg.configs)}"
+ '';
+
+ }
+ // (mapAttrs' (name: subvolume: nameValuePair "snapper/configs/${name}" ({
+ text = ''
+ ${subvolume.extraConfig}
+ FSTYPE="${subvolume.fstype}"
+ SUBVOLUME="${subvolume.subvolume}"
+ '';
+ })) cfg.configs)
+ // (lib.optionalAttrs (cfg.filters != null) {
+ "snapper/filters/default.txt".text = cfg.filters;
+ });
+
+ };
+
+ services.dbus.packages = [ pkgs.snapper ];
+
+ systemd.services.snapper-timeline = {
+ description = "Timeline of Snapper Snapshots";
+ inherit documentation;
+ serviceConfig.ExecStart = "${pkgs.snapper}/lib/snapper/systemd-helper --timeline";
+ };
+
+ systemd.timers.snapper-timeline = {
+ description = "Timeline of Snapper Snapshots";
+ inherit documentation;
+ wantedBy = [ "basic.target" ];
+ timerConfig.OnCalendar = cfg.snapshotInterval;
+ };
+
+ systemd.services.snapper-cleanup = {
+ description = "Cleanup of Snapper Snapshots";
+ inherit documentation;
+ serviceConfig.ExecStart = "${pkgs.snapper}/lib/snapper/systemd-helper --cleanup";
+ };
+
+ systemd.timers.snapper-cleanup = {
+ description = "Cleanup of Snapper Snapshots";
+ inherit documentation;
+ wantedBy = [ "basic.target" ];
+ timerConfig.OnBootSec = "10m";
+ timerConfig.OnUnitActiveSec = cfg.cleanupInterval;
+ };
+ });
+}
+
diff --git a/nixos/release.nix b/nixos/release.nix
index 467e3bb8cd61..0dbdadf97816 100644
--- a/nixos/release.nix
+++ b/nixos/release.nix
@@ -303,6 +303,7 @@ in rec {
tests.simple = callTest tests/simple.nix {};
tests.slim = callTest tests/slim.nix {};
tests.smokeping = callTest tests/smokeping.nix {};
+ tests.snapper = callTest tests/snapper.nix {};
tests.taskserver = callTest tests/taskserver.nix {};
tests.tomcat = callTest tests/tomcat.nix {};
tests.udisks2 = callTest tests/udisks2.nix {};
diff --git a/nixos/tests/snapper.nix b/nixos/tests/snapper.nix
new file mode 100644
index 000000000000..74ec22fd3499
--- /dev/null
+++ b/nixos/tests/snapper.nix
@@ -0,0 +1,43 @@
+import ./make-test.nix ({ ... }:
+{
+ name = "snapper";
+
+ machine = { pkgs, lib, ... }: {
+ boot.initrd.postDeviceCommands = ''
+ ${pkgs.btrfs-progs}/bin/mkfs.btrfs -f -L aux /dev/vdb
+ '';
+
+ virtualisation.emptyDiskImages = [ 4096 ];
+
+ fileSystems = lib.mkVMOverride {
+ "/home" = {
+ device = "/dev/disk/by-label/aux";
+ fsType = "btrfs";
+ };
+ };
+ services.snapper.configs.home.subvolume = "/home";
+ services.snapper.filters = "/nix";
+ };
+
+ testScript = ''
+ $machine->succeed("btrfs subvolume create /home/.snapshots");
+
+ $machine->succeed("snapper -c home list");
+
+ $machine->succeed("snapper -c home create --description empty");
+
+ $machine->succeed("echo test > /home/file");
+ $machine->succeed("snapper -c home create --description file");
+
+ $machine->succeed("snapper -c home status 1..2");
+
+ $machine->succeed("snapper -c home undochange 1..2");
+ $machine->fail("ls /home/file");
+
+ $machine->succeed("snapper -c home delete 2");
+
+ $machine->succeed("systemctl --wait start snapper-timeline.service");
+
+ $machine->succeed("systemctl --wait start snapper-cleanup.service");
+ '';
+})