feat(youko): restic backup of the service state to the HDD (part 1) (#299)

* feat(hosts): per-host restic backups to the SMB remote

Replace the disabled aggregate job (which rsynced every host's /persistent
into /tmp - a tmpfs - and silently produced incomplete backups) with one
restic repo per host, backing up its own data directly:

- shared module lib/genResticBackup.nix: secrets, rclone/SMB target
  (rclone:smb-downloads:/Downloads/homelab-backup/<host>), retention, and
  excludes for the regenerable/bulky paths (podman overlay layers, microvm
  and libvirt disk images, nfs golden images, logs, caches)
- youko additionally backs up the service state under /persistent/var/lib
  and dumps all postgres databases first (a consistent SQL artifact beside
  the crash-consistent data dir)
- shoryu/shushou back up /etc/agenix, /etc/ssh and the k3s token

Needs three agenix secrets before it can run: restic-password,
ssh-key-for-restic-backup and rclone-conf-for-restic-backup.

* refactor(hosts): only restic does off-host; btrbk stays local

btrbk works at the subvolume level, so a  would ship the whole
@persistent - including podman's overlay storage, the microVM/libvirt
images and the nfs golden images - which is exactly the regenerable bulk
we want out of the backups.

So: btrbk keeps making the cheap local CoW snapshots (quick rollbacks),
and restic owns the off-host copies with file-level excludes. Back up the
whole /persistent minus those excludes, so nothing is missed.

Update the btrbk module's comment to say so.

* fix(restic): never back up keys; drop the implicit /etc/agenix

restic's own password lives in /etc/agenix, so backing that up would store
the repository's password inside the repository. Exclude agenix, the ssh
host keys and the usual credential dirs (.ssh, .gnupg, .aws, gcloud).

* refactor(restic): make the backup a proper module

Replace lib/genResticBackup.nix with modules/nixos/base/restic-backup.nix,
which declares modules.restic-backup options (enable, repository, paths,
exclude, requiresMountsFor, postgresDump) like the btrbk module does.
youko just sets the options now.

* refactor(restic): exclude the whole podman storage, trim retention

Exclude /persistent/var/lib/containers wholesale: the image layers are
re-pullable and podman's storage DB is not consistent when copied from a
running podman. uptime-kuma's named volume lives there, but its data is
not wanted anyway.

Retention: daily 3, weekly 2, monthly 2 - no yearly.

* refactor(restic): back up a short-lived btrfs snapshot, drop pg_dump

A live copy of a running postgres data dir is only crash-consistent. Instead
of a daily pg_dumpall (which duplicates the data in the repository and gets
slower as the DB grows), take a short-lived read-only btrfs snapshot in
backupPrepareCommand, back that up, and delete it right after - a
consistency step, not a second backup. The excludes are relative now so they
work for both the snapshot and a direct path.

* feat(secrets): restic-password for modules.restic-backup

With the rekeyed secret in nix-secrets (storage group, alongside
hdd-luks-crypt-key), the module's passwordFile resolves.

* fix(restic): agenix's secret is under /run/agenix, not /etc/agenix

* fix(restic): take the password path from agenix, not a hardcoded one

config.age.secrets."restic-password".path follows agenix's secretsDir, so it
stays correct if that changes.
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-09-21 19:00:35 +08:00
1 parent bb33cca83a
commit e37042f61c
6 files changed
+159 -92

No files matched your search

Generated
+3 -3
View File
@@ -503,10 +503,10 @@
"mysecrets": {
"flake": false,
"locked": {
"lastModified": 1789975872,
"narHash": "sha256-DQrX1au7BWEzXJqR2FBiSYdF0DWd5NMh12y6ovtn41o=",
"lastModified": 1789987762,
"narHash": "sha256-gfrl1P3WdGBMdOZ9gaGg1UQwjGGwPzcLQUjUEhmBJOE=",
"ref": "refs/heads/main",
"rev": "4def15deec5c0538df16ad2d49028298ee81d7ab",
"rev": "00f4f7fdf580eb1807e742977fd581dde137e575",
"shallow": true,
"type": "git",
"url": "ssh://git@github.com/ryan4yin/nix-secrets.git"
+10
View File
@@ -28,6 +28,16 @@ in
coreModule
];
# Back up the preserved tree through a short-lived read-only snapshot, so the
# backup is a consistent point-in-time; the module's excludes drop the
# regenerable bulk and all keys/credentials.
modules.restic-backup = {
enable = true;
repository = "/data/backups/restic/youko";
snapshotSource = "/btr_pool/@persistent";
requiresMountsFor = "/data/backups";
};
modules.btrbk.enable = true;
boot.kernelParams = [
@@ -1,87 +0,0 @@
{ pkgs, ... }:
let
passwordFile = "/etc/agenix/restic-password";
sshKeyPath = "/etc/agenix/ssh-key-for-restic-backup";
rcloneConfigFile = "/etc/agenix/rclone-conf-for-restic-backup";
in
{
# DISABLED: this backup has never worked. It needs three agenix secrets that
# do not exist in nix-secrets yet (`restic-password`, `ssh-key-for-restic-backup`,
# and an rclone config with the `smb-downloads` remote; the existing
# `rclone.conf` secret is only defined for the desktop secret set). Re-enable
# once the secrets are provisioned.
#
# https://github.com/NixOS/nixpkgs/blob/nixos-26.05/nixos/modules/services/backup/restic.nix
/*
services.restic.backups = {
homelab-backup = {
inherit passwordFile;
initialize = true; # Initialize the repository if it doesn't exist.
repository = "rclone:smb-downloads:/Downloads/kubevirt-backup/"; # backup to a rclone remote
# rclone related
# rcloneOptions = {
# bwlimit = "100M"; # Limit the bandwidth used by rclone.
# };
inherit rcloneConfigFile;
# Which local paths to backup, in addition to ones specified via `dynamicFilesFrom`.
paths = [
"/tmp/restic-backup-temp"
];
#
# A script that produces a list of files to back up. The
# results of this command are given to the '--files-from'
# option. The result is merged with paths specified via `paths`.
# dynamicFilesFrom = "find /home/matt/git -type d -name .git";
#
# Patterns to exclude when backing up. See
# https://restic.readthedocs.io/en/latest/040_backup.html#excluding-files
# for details on syntax.
exclude = [ ];
# A script that must run before starting the backup process.
backupPrepareCommand = ''
${pkgs.nushell}/bin/nu -c '
let kubevirt_nodes = [
"shoryu"
"shushou"
"youko"
]
kubevirt_nodes | each {|it|
^${pkgs.rsync}/bin/rsync -avz -e $"ssh -i ${sshKeyPath}" $"($it):/persistent/" $"/tmp/restic-backup-temp/($it)"
}
'
'';
# A script that must run after finishing the backup process.
backupCleanupCommand = "rm -rf /tmp/restic-backup-temp";
# Extra extended options to be passed to the restic --option flag.
# extraOptions = [];
# Extra arguments passed to restic backup.
# extraBackupArgs = [
# "--exclude-file=/etc/restic/excludes-list"
# ];
# repository = "/mnt/backup-hdd"; # backup to a local directory
# When to run the backup. See {manpage}`systemd.timer(5)` for details.
timerConfig = {
OnCalendar = "01:30";
RandomizedDelaySec = "1h";
};
# A list of options (--keep-* et al.) for 'restic forget --prune',
# to automatically prune old snapshots.
# The 'forget' command is run *after* the 'backup' command, so
# keep that in mind when constructing the --keep-* options.
pruneOpts = [
"--keep-daily 3"
"--keep-weekly 3"
"--keep-monthly 3"
"--keep-yearly 3"
];
};
};
*/
}
+6 -2
View File
@@ -19,8 +19,12 @@ in
# /btr_pool/@snapshots/@persistent.<timestamp>
#
# These are same-filesystem snapshots: they protect against accidental
# deletion and bad edits, NOT against disk loss. Set `target` (plus
# `services.btrbk.sshAccess` on the receiving host) for off-host backups.
# deletion and bad edits, NOT against disk loss. Off-host copies are handled
# by restic (lib/genResticBackup.nix), which can exclude the regenerable bulk
# at the file level; btrbk works at the subvolume level, so a `target` would
# also ship podman's overlay storage and the VM disk images. Set `target`
# (plus `services.btrbk.sshAccess` on the receiving host) only if you want
# that.
#
# The host MUST mount the btrfs top-level subvolume (subvolid=5) at `volume`;
# this is enforced by an assertion so a missing mount fails evaluation instead
+134
View File
@@ -0,0 +1,134 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.modules.restic-backup;
# A short-lived, read-only btrfs snapshot taken by the backup itself, so the
# backup is a consistent point-in-time (a live copy of, say, a running
# postgres data directory is only crash-consistent). It is deleted right
# after, so it is not a second backup - just a consistency step.
snapshotPath = "${builtins.dirOf cfg.snapshotSource}/@restic-snapshot";
in
{
# ==================================================================
#
# A restic backup to a LOCAL repository (part 1 of the backup plan).
#
# Part 2 (copying the critical snapshots to a cloud object store with
# `restic copy`) is a later step. btrbk keeps making the cheap local btrfs
# snapshots for rollbacks; off-host copies are restic's job (restic can
# exclude the regenerable bulk at the file level).
#
# Keys and credentials are NEVER included: restic's own password lives in
# /etc/agenix, so backing that up would store the repository's password
# inside the repository.
#
# ==================================================================
options.modules.restic-backup = {
enable = lib.mkEnableOption "a restic backup (local repository)";
repository = lib.mkOption {
type = lib.types.str;
example = "/data/backups/restic/youko";
description = "Restic repository, e.g. a path under a mounted backup disk.";
};
snapshotSource = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/btr_pool/@persistent";
description = ''
btrfs subvolume to back up through a short-lived read-only snapshot.
When null, `paths` is backed up directly.
'';
};
paths = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Paths to back up (used when {option}`snapshotSource` is null).";
};
exclude = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Extra exclude patterns, on top of the built-in ones.";
};
requiresMountsFor = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Mount point the repository lives on, when it is a `nofail` mount: the
backup is then ordered after that mount instead of running against a
not-yet-mounted directory.
'';
};
};
config = lib.mkIf cfg.enable {
services.restic.backups.homelab = {
inherit (cfg) repository;
initialize = true;
# take the path from agenix itself instead of hardcoding its secretsDir
passwordFile = config.age.secrets."restic-password".path;
paths = if cfg.snapshotSource != null then [ snapshotPath ] else cfg.paths;
# Relative to the backed-up tree, so they work for both the snapshot and
# a direct path.
exclude = [
# regenerable, huge, or unsuitable for file-level backup
#
# the whole podman storage tree: the image layers are re-pullable, and
# its storage DB is not consistent when copied from a running podman.
# (uptime-kuma uses a named volume under here; its data is not wanted.)
"var/lib/containers"
"var/lib/microvms"
"var/lib/libvirt"
"nfs"
"var/cache"
"var/tmp"
"var/log"
"*.qcow2"
# keys and credentials are never backed up
"etc/agenix"
"etc/ssh/ssh_host_*"
"**/.ssh"
"**/.gnupg"
"**/.aws"
"**/.config/gcloud"
]
++ cfg.exclude;
timerConfig = {
OnCalendar = "01:30";
RandomizedDelaySec = "1h";
};
pruneOpts = [
"--keep-daily 3"
"--keep-weekly 2"
"--keep-monthly 2"
];
}
// lib.optionalAttrs (cfg.snapshotSource != null) {
backupPrepareCommand = ''
${pkgs.btrfs-progs}/bin/btrfs subvolume snapshot -r ${cfg.snapshotSource} ${snapshotPath}
'';
backupCleanupCommand = ''
${pkgs.btrfs-progs}/bin/btrfs subvolume delete ${snapshotPath}
'';
};
systemd.services.restic-backups-homelab.unitConfig =
lib.optionalAttrs (cfg.requiresMountsFor != null)
{
RequiresMountsFor = cfg.requiresMountsFor;
};
};
}
+6
View File
@@ -220,6 +220,12 @@ in
mode = "0400";
owner = "root";
};
# restic repository password (used by modules.restic-backup)
"restic-password" = {
file = "${mysecrets}/restic-password.age";
mode = "0400";
owner = "root";
};
};
# place secrets in /etc/