rsync backups for NixOS

· 595 words · 3 minute read

There are several nice options in NixOS for specifying scheduled backup jobs, including borgbackup, borgmatic and restic. But I’ve often used rsync in the past, and there is no such module for scheduled rsync jobs (or rsnapshot either). So I wrote my own simple module.

The idea of the module is to run on a backup server and pull important files from other servers via ssh and rsync. Distributed systems tend to work better when pulling instead of pushing. It uses systemd timers, so you can list the jobs using systemctl list-timers.

{ lib, config, pkgs, ... }:
with lib;
let cfg = config.services.rsync-pull-backup;
in {
  options.services.rsync-pull-backup = with types; {
    enable = mkEnableOption "pull backup with rsync";
    key = mkOption {
      type = str;
      description = "SSH keypair to use";
      example = "/root/id_backup";
    };
    jobs = mkOption {
      description = "Backup jobs";
      type = attrsOf (submodule {
        options = {
          enable = (mkEnableOption "this rsync job") // {
            default = true;
            example = false;
          };
          key = mkOption {
            type = str;
            description = "SSH keypair to use";
            default = cfg.key;
            example = "/root/id_backup";
          };
          schedule = mkOption {
            type = str;
            description = "<literal>systemd.time</literal> schedule for backup";
            default = "daily UTC";
            example = "*-*-* 01:00:00";
          };
          source = mkOption {
            type = str;
            description = "rsync source, remember rrsync and trailing /";
            example = "root@srv1.example.com:/";
          };
          destination = mkOption {
            type = str;
            description = "rsync destination, remember trailing /";
            example = "/srv/backup/srv1.example.com";
          };
          rules = mkOption {
            type = str;
            description =
              "rsync filter rules, see <literal>man rsync</literal>";
            default = "";
            example = ''
              + /srv
              - *
            '';
          };
        };
      });
      default = { };
      example = literalExpression ''
        {
          "srv1.example.com" = {
            source = "root@srv1.example.com:/";
            destination = "/srv/backup/srv1.example.com/";
          };
        };
      '';
    };
  };

  config.systemd = mkIf cfg.enable {
    services = mapAttrs' (name: jobcfg:
      nameValuePair "rsync-pull-backup@${name}" {
        description = "rsync pull backup ${name}";
        enable = jobcfg.enable;
        serviceConfig = {
          Type = "oneshot";
          ExecStart = "${
              pkgs.writeShellApplication {
                name = "rsync-pull-backup-${name}.sh";
                runtimeInputs = [ pkgs.rsync pkgs.openssh ];
                text = ''
                  rsync -e 'ssh -i ${jobcfg.key}' -azHAX ${
                    if jobcfg.rules == "" then
                      ""
                    else
                      "-f 'merge ${
                        pkgs.writeText "${name}.rsync.rules" "${jobcfg.rules}"
                      }' "
                  }--zc=zstd --delete --delete-excluded ${jobcfg.source} ${jobcfg.destination}
                '';
              }
            }/bin/rsync-pull-backup-${name}.sh";
        };
      }) cfg.jobs;
    timers = mapAttrs' (name: jobcfg:
      nameValuePair "rsync-pull-backup@${name}" {
        description = "rsync pull backup ${name}";
        enable = jobcfg.enable;
        wantedBy = [ "timers.target" ];
        timerConfig = {
          Unit = "rsync-pull-backup@${name}.service";
          OnCalendar = jobcfg.schedule;
          RandomizedDelaySec = 1800;
          FixedRandomDelay = true;
          Persistent = true;
        };
      }) cfg.jobs;
  };
}

Rsync is a powerful tool, and for backing up you usually need to connect with a privileged account in order to be able to read all files. The SSH connection should be protected and limited to just reading the files. rrsync will help with that. And this module will help with rrsync:

{ lib, config, pkgs, ... }:
with lib;
let cfg = config.services.rrsync;
in {
  options.services.rrsync = with types; {
    paths = mkOption {
      description = "paths to expose via rrsync";
      type = attrsOf (submodule {
        options = {
          key = mkOption {
            type = str;
            description = "SSH public key, without options";
            example = "ssh-ed25519 AAAA... user@example";
          };
          readOnly = mkOption {
            type = bool;
            description = "expose read-only";
            default = true;
            example = false;
          };
        };
      });
      default = { };
      example = literalExpression ''
        {
          "/srv" = {
            key = "ssh-ed25519 AAAA... user@example";
            readOnly = true;
          };
        };
      '';
    };
  };

  config.users.users.root.openssh.authorizedKeys.keys = mapAttrsToList
    (name: share:
      ''
        command="${pkgs.rrsync}/bin/rrsync ${
          if share.readOnly then "-ro " else ""
        }${name}",restrict ${share.key}'') cfg.paths;
}