Add B2 automated backup with restic

- Add services.postgresqlBackup for daily DB dumps (2 AM)
- New modules/backup-b2.nix: restic backup to B2 (3 AM daily)
- Weekly integrity check (Sunday 4 AM)
- Retention: 7 daily, 4 weekly, 6 monthly
- B2 bucket: ops-jrz1-backup with scoped app key

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dan 2026-01-10 13:49:59 -08:00
parent ff34cee51e
commit 31d388d21c
4 changed files with 185 additions and 20 deletions

View file

@ -20,6 +20,7 @@
../modules/security/ssh-hardening.nix ../modules/security/ssh-hardening.nix
../modules/matrix-secrets ../modules/matrix-secrets
../modules/backup.nix ../modules/backup.nix
../modules/backup-b2.nix
]; ];
# System configuration # System configuration
@ -102,6 +103,9 @@
# Local backup service (Phase 1: manual trigger) # Local backup service (Phase 1: manual trigger)
services.backup.enable = true; services.backup.enable = true;
# B2 offsite backup (daily automated via restic)
services.backup-b2.enable = true;
# Security hardening - DISABLED pending fixes # Security hardening - DISABLED pending fixes
# security.fail2ban-enhanced.enable = true; # security.fail2ban-enhanced.enable = true;
# security.ssh-hardening.enable = true; # security.ssh-hardening.enable = true;

148
modules/backup-b2.nix Normal file
View file

@ -0,0 +1,148 @@
# B2 backup module using restic
# Backs up PostgreSQL dumps and application state to Backblaze B2
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.services.backup-b2;
backupPaths = [
"/var/backup/postgresql" # DB dumps from services.postgresqlBackup
"/var/lib/matrix-continuwuity" # Matrix homeserver (RocksDB)
"/var/lib/forgejo" # Git hosting (repos, avatars)
"/var/lib/mautrix-slack" # Bridge state
"/var/lib/maubot" # Bot data
];
excludePatterns = [
"*.sock"
"*.pid"
"*.log"
"**/.git/objects/pack/*.tmp" # Git temp files
];
# Script to set up restic environment from sops secrets
resticEnvScript = ''
export RESTIC_PASSWORD_FILE="${config.sops.secrets."restic/password".path}"
export B2_ACCOUNT_ID="$(cat "${config.sops.secrets."restic/b2_account_id".path}")"
export B2_ACCOUNT_KEY="$(cat "${config.sops.secrets."restic/b2_account_key".path}")"
export RESTIC_REPOSITORY="$(cat "${config.sops.secrets."restic/b2_repo".path}")"
'';
in
{
options.services.backup-b2 = {
enable = mkEnableOption "B2 backup service using restic";
};
config = mkIf cfg.enable {
# Restic package available system-wide for manual operations
environment.systemPackages = [ pkgs.restic ];
# sops secrets for restic (defined here, values in secrets.yaml)
sops.secrets = {
"restic/password" = {
mode = "0400";
};
"restic/b2_account_id" = {
mode = "0400";
};
"restic/b2_account_key" = {
mode = "0400";
};
"restic/b2_repo" = {
mode = "0400";
};
};
# Backup service
systemd.services.backup-b2 = {
description = "Restic backup to Backblaze B2";
after = [ "network-online.target" "postgresql.service" ];
wants = [ "network-online.target" ];
# Don't require postgres - backup should still run even if DB is down
# (will just skip the dump files if they don't exist)
serviceConfig = {
Type = "oneshot";
User = "root";
# Low priority
IOSchedulingClass = "idle";
Nice = 19;
};
path = [ pkgs.restic ];
script = ''
set -euo pipefail
${resticEnvScript}
# Initialize repo if it doesn't exist (idempotent)
if ! restic snapshots &>/dev/null; then
echo "Initializing restic repository..."
restic init
fi
echo "Starting backup..."
restic backup \
${concatMapStringsSep " " (p: lib.escapeShellArg p) backupPaths} \
${concatMapStringsSep " " (e: "--exclude ${lib.escapeShellArg e}") excludePatterns} \
--tag ops-jrz1 \
--tag automated \
--verbose
echo "Pruning old snapshots..."
restic forget \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 6 \
--prune
echo "Backup complete."
restic snapshots --latest 3
'';
};
# Daily backup timer - 3 AM (after postgresql backup at 2 AM)
systemd.timers.backup-b2 = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-*-* 03:00:00";
Persistent = true; # Run if missed (e.g., server was down)
RandomizedDelaySec = "5m";
};
};
# Weekly integrity check service
systemd.services.backup-b2-check = {
description = "Verify B2 backup integrity";
serviceConfig = {
Type = "oneshot";
User = "root";
IOSchedulingClass = "idle";
Nice = 19;
};
path = [ pkgs.restic ];
script = ''
set -euo pipefail
${resticEnvScript}
echo "Checking backup integrity (5% data sample)..."
restic check --read-data-subset=5%
echo "Integrity check passed."
'';
};
# Weekly integrity check timer - Sunday 4 AM
systemd.timers.backup-b2-check = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "Sun *-*-* 04:00:00";
Persistent = true;
};
};
};
}

View file

@ -116,6 +116,14 @@ in
}; };
}; };
# PostgreSQL backup service (dumps for restic to pick up)
services.postgresqlBackup = {
enable = true;
databases = [ "forgejo" ] ++ optional cfg.slackBridge.enable "mautrix_slack";
location = "/var/backup/postgresql";
startAt = "*-*-* 02:00:00"; # 2 AM daily, before restic at 3 AM
};
# Matrix Continuwuity server # Matrix Continuwuity server
systemd.services.matrix-continuwuity = mkIf cfg.matrix.enable { systemd.services.matrix-continuwuity = mkIf cfg.matrix.enable {
description = "Continuwuity Matrix homeserver"; description = "Continuwuity Matrix homeserver";

View file

@ -1,32 +1,37 @@
matrix-registration-token: ENC[AES256_GCM,data:j7i/qtPol4dFtIdcBBfiJPrmIcNv0oeGU0Et/rbYwiC7eqAfh4v0xcS9ymzMJZXt75ikLEy8gJBm0i3kzuY8Tw==,iv:t5vQ7NQ3Mq1xnplgkdWu/XJlN/YEedVp+hRCbazy7YM=,tag:soQm891iwqgxZuYNoNFVYw==,type:str] matrix-registration-token: ENC[AES256_GCM,data:SiF+6nXlxpPddr83/CR0o1rs3jde6ewxKxMXpJ9t4vz6x0xaC4tFEIWh6u8xyrX/6ORqqPZr0mWsbWwNJM+MfA==,iv:Z6/irfWu8YCuSvtYmVGSkz8GhVUeuN5fP7qsVXlNUdE=,tag:fS8NfiT5ErhVMDOkKyac1w==,type:str]
acme-email: ENC[AES256_GCM,data:178eat1kqzemxmHJ4w==,iv:27x07i90//RA/Lvs/N8ITOU+abcrfpOoCZiOV932MAY=,tag:NStHMV22Bsq/nbyobbR54w==,type:str] acme-email: ENC[AES256_GCM,data:A97cinBoMWHpCpAM9A==,iv:VrROWl9HfVKZT4aq1T23puCUkbeoCbDRJbCqpOzCKG8=,tag:eXQ3IiMjn2njYgue1NvQog==,type:str]
slack-app-token: ENC[AES256_GCM,data:s9TAQvQH4QpRyBQFAU3aVgjyLzLLIqqTCXVV8mHv2ITNyFNWd5lveyGFzmuDmU3qPW5/S0ZuYMkuSkZrREVPH57Kbv19dR9/fTe0keIbtLC9FmZn2yRZdjKvjgGMIKeWsA==,iv:mwxEVj6bsghkXZ0v6IH6JA2JZfCoknjyOa8FTdOP2OQ=,tag:9N5EoUXsMU41hICaWMtVaw==,type:str] slack-app-token: ENC[AES256_GCM,data:eXNpliGcPwrnc3pqRlaLmbjjVTKmJOQlchKt7uDlEEH4mWnzsm87VrRdi3usrVAJT+il7zapd+VQK5fEOcJwP1b4hGp1PvEFdiN1oofDKenRGNyptzolRA3cyhdQsCZVZA==,iv:Qc9aYgm5g7T6kG4hG9TGdK6RufosGnVbSV0IhguhHuk=,tag:fY9V4AKTcUvHKcMp/OoG2w==,type:str]
maubot-admin-password: ENC[AES256_GCM,data:iJ3lZKaPWIOoVrkC0qx5tzxOdbks7c3J9WrR1c3KgpSrtzfiZtl36PPZFqs=,iv:P708rzoWfrcUWqDdU1Vw80xGQVYwwRYI1g/i6rDhOMU=,tag:x/Xjq7w/k7Qr4hcuuSEHYg==,type:str] maubot-admin-password: ENC[AES256_GCM,data:C8s7rPrPI3V7NYksNVw8CW10QGR8iAnWo2yVO2i3Jv/3AU/dza3pwbu4bRQ=,iv:qVLpFC3BYQ48hem3I5msRt5s8nqf2WSGyeOIw1Ior70=,tag:nOXpD1pdb+GRBBPjhobqKg==,type:str]
maubot-secret-key: ENC[AES256_GCM,data:8GZjOJo/Txl7aQf/jlHgctcmwk47CFP75tZyLfnbnlcgsEjCbclxxKJEv0zYZ4z270pQ9ieMx6JNGD6z61iSpw==,iv:3H9DJYAZiNaW4DSbREajaLnUXufxo1h9BUm2gYFPW/Y=,tag:y+7hQDlszhopnTaoHZ48yg==,type:str] maubot-secret-key: ENC[AES256_GCM,data:k8meKgyRYhYqR86GjpH0xCPJsrvKxy4LTHM4PJct0TmZnBatqpWoO6abqzPnsuyA8PHipAz4Yw7+VyXsUdifkQ==,iv:uUYuMja1X8U7FMYj1oOGVIZ/Opfoi/Zo/a7gQIS4FCQ=,tag:z8vVRohp5iREil3lW4ZoHg==,type:str]
slack-bot-token: ENC[AES256_GCM,data:JRV8Fw2I9YMXttXWqPTlm1/2chF4m8KOilzsPuIyX8V7BYYb4uXlgW53MgfVcScXrgp981q7jL0=,iv:xJGHpI1WkZmRt5n9ZJmiu8IbdazrQJAb/ztHw5v7mXA=,tag:ajjAtWsOkXLV1iNQOf/h1g==,type:str] slack-bot-token: ENC[AES256_GCM,data:2d1GNPvNwJBN8S2fBzL6E8fh6D2hGU8aFPEaNYHCfM+AhrzGctnzk3pgTOTpUWkXHDp5bCaxFGw=,iv:7lHPLQyL+GzH1siujx517BPQ+BlQXbuDbHMpaNH+MrQ=,tag:Qt/KiiFBHnbU5lz9mUWhvg==,type:str]
forgejo-admin-password: ENC[AES256_GCM,data:+ckJhKS7ive6h/dxru7IZ2fjW7MRnMW2,iv:om0MfDwFRTqPgVETcelnmmKX4BtZfbO1feBseZ2kO0s=,tag:FirgrQwCM+fUab85liBWiw==,type:str] forgejo-admin-password: ENC[AES256_GCM,data:ih8u+gNJpYcmtWD/0voNBc+dyDf7aQaC,iv:WIrD3IgwUJZIR8BrO5ok1uL5YXdG4I+HgEzwvjnLaiw=,tag:Xon/rtoXQF1SbDtfDZYaiQ==,type:str]
forgejo-api-token: ENC[AES256_GCM,data:a7IZXAnzg6CS+GHS/grqaw5InbMoTs9igDI66sMe4z+A3tH4xDCtWQ==,iv:u+cSLF5w4MxO4yWblHscfEi1KzJnbSqONL5LMYBpQE4=,tag:XaxX1t3gcxfN2Z/xKfN8SA==,type:str] forgejo-api-token: ENC[AES256_GCM,data:eOYykB65PbfMnFeW3U0l5HmV3yBtQ7pPlYdUfZsupRIO/8UTrCqfLw==,iv:8vw18fr/e8kSD2U09BN3GWzmJ7GzdSPssZDExazY6Jc=,tag:rMuAD+iqSZCfOtEWY1fJgA==,type:str]
restic:
password: ENC[AES256_GCM,data:ydVeGcvZThSYtXpMsgVxkVvykQDlvI5niy/YTHJ14h0=,iv:PLDgROTsRMbLXgnBkkPosfoJ/SV6Ejx46o8FFYZAGPQ=,tag:vxARTz5XAsaHr+jrtVjt9w==,type:str]
b2_account_id: ENC[AES256_GCM,data:Arz8ZZ8ahVNjvlPlVEbRbLzWMmLS3AXdgQ==,iv:YcOcAFIs3KjWEpMVOM7mtBGmdrh4IG47/esZIyxeUTg=,tag:eOi7Z4CW9DgzDFjjQwGsdg==,type:str]
b2_account_key: ENC[AES256_GCM,data:9jpuaRaGtGxz+Bp9wlOr5apECRorkpsC7+4+wrxUAQ==,iv:WFwKWc1yR8sU7xKVZ40RkCCGVzChGMxLLc5L6D12UBY=,tag:SwLzS6MwA0O1G6XwHo1LtA==,type:str]
b2_repo: ENC[AES256_GCM,data:JcvCGIJunXFq99mfo7+GQVjR,iv:ym7mu7yNwNZ2MuViKaEe6WXLuS58k++nFA42ZJHsbis=,tag:0mBsoVKY7aTzv4SxB8Jcyg==,type:str]
sops: sops:
age: age:
- recipient: age1vuxcwvdvzl2u7w6kudqvnnf45czrnhwv9aevjq9hyjjpa409jvkqhkz32q - recipient: age1vuxcwvdvzl2u7w6kudqvnnf45czrnhwv9aevjq9hyjjpa409jvkqhkz32q
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwMUhsUGl6dncxaGVScTVo YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuTm1DNzZjM1VmSVU2NlA0
a3crTjdGaXpvNm5Xb2lHN3UwNWFzckRUempJCmNzK1E4dW5BVlZPMk5JS3ZtS21K RUFpZ2h0Snc0ZlVsc04zanBQZmcyaFh0aUc4CnNOQ0ZUS25xeTUwV1A0MzZFQzBT
Nm40bGZzV0M3Z3JjaFA1Qit6S001dVkKLS0tIGQwODVJT1ZTeTNsSHZNd0IvSnNu NmhjVzVZR2gzb21iTFM2cDJRV0NDa3cKLS0tIG85RnRzYWQvNjNUMnN2bUJ6UUNn
SmhqQ1RtUHloL1RwTWhlN0NxMTFrc0kKiJRArc6hfwRNQqI9zWnJjvgpD+RrYT8S Smp1ZkMwZ1RBb1NmWExYc280c20zYjAKwyChuUih0BTk7nYsit6aBkGDAzJV0xBa
huj/komeDL3+gUJgXdbxvXKczLjtUf6bOjSm/BgwFiLG/dr1meWV/Q== gi2/bk5uLk7cW+JU46IrK9VPN6VhexDqN0k9ub3YMXNxfurn3wMNIg==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
- recipient: age18ue40q4fw8uggdlfag7jf5nrawvfvsnv93nurschhuynus200yjsd775v3 - recipient: age18ue40q4fw8uggdlfag7jf5nrawvfvsnv93nurschhuynus200yjsd775v3
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBazZpazREcER4OGpRVEhl YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkWlVpbGFPY04wbjVhQndX
UFE4My9paDdZMW45ZUQzbC9IT0s5NXc1NldNCm5GaHIvM29lN1ZiL0FobVJvWjBP cEpuVzliTkFocUhDMGhnNmNxek9pcXNLeEFRCmNWc1dHMFBpMWcxYkg2MEdYTjRs
MEVQVFpMSHZWTkxOQkgyYkxzVjVYN00KLS0tIHVHeE5MU2QwSVNzcldaWURzdDlo YjVreWpHbng0dTBIQkM2QzBHM0EwMmMKLS0tIGFkYVprTytHSEc4TXR1NGc0V2VC
RU1TR24zOGxnd0syeVlvc3JHeGFhTTAKwg1TvZ6ixaDgBfz+3auoLVjdXnHuzvGv TFF5cmRNSFE2ODBjOVBTemFNUzQrREEKyzPRDrmR68VKmjDLoJ89Yz+9A0tQPMB9
pN+pUklEsaymDCun3rEUGiI0xA08WML1HAE7AyfqKa32wSJUOpmcMA== 1+0/F+3OAbk66FTycap4E2mIqxLFWifW2h7tOfP2exxXFktCQcgmyA==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
lastmodified: "2026-01-09T01:45:06Z" lastmodified: "2026-01-10T21:48:44Z"
mac: ENC[AES256_GCM,data:gbYiINSWDI0Bhgrlv8A1ImUciQg17WT47RlN8VZfrbGfa6PKdnUQncrjuKBjMdVXhk2e6sIvLO2OGXJp2dznK8DEKJOeeHamKo6k5PnrAe81tLLI5wub6+q4vARwqV8SC5JJAMDT8+H+PQZ25ao98usJ4A9EJ4zD6EQ4Tbff4g8=,iv:CElQ1xzT7Z0VZW452hTR/DcSnfplW5GBfYOUalOP4nU=,tag:+nSgn2eEstS8aZD99jxuYw==,type:str] mac: ENC[AES256_GCM,data:HGGz77ONHpz/OjwJU1+F+D+MJyHJP/UrCytjrYKTRK1pirNsJWyCwWDSKkpXvLt3vgJBlnWLgzbCk9Bp7NpYOO+QooRETdIqaZHSpEGoQjcJjY1o/8j4/THxwTb0Yh5mVZKQg39tEGIFOIcYc8HLPBLGQEbh6JGN2F/4r5PseWI=,iv:70QniATCsMmRfpdPbDspUle35Okxj1y4AhEJvY1CpQI=,tag:HnsKH/qqC9bUsk2aHSkTZQ==,type:str]
unencrypted_suffix: _unencrypted unencrypted_suffix: _unencrypted
version: 3.11.0 version: 3.11.0