Kieran's opinionated (and probably slightly dumb) nix config

feat: add full restic backups on b2

dunkirk.sh 1f8b22d4 b8ef77f0

verified
+1412 -122
+1
.envrc
··· 1 + eval "$(nix print-dev-env)"
+17
README.md
··· 24 24 │ ├── tacyon # rpi 5 25 25 │ └── terebithia # oracle cloud aarch64 server 26 26 ├── modules 27 + │ ├── lib # shared nix utilities 28 + │ │ └── mkService.nix # base service factory 27 29 │ ├── home # home-manager modules 28 30 │ │ ├── aesthetics # theming and wallpapers 29 31 │ │ ├── apps # any app specific config ··· 33 35 │ │ └── hyprland 34 36 │ └── nixos # nixos modules 35 37 │ ├── apps # also app specific configs 38 + │ ├── services # self-hosted services with automatic backup 39 + │ │ └── restic # backup system (see modules/nixos/services/restic/README.md) 36 40 │ └── system # pam and my fancy wifi module for now 37 41 └── secrets # keep your grubby hands (or paws) off my data 38 42 ··· 241 245 atuin login 242 246 atuin sync 243 247 ``` 248 + 249 + ## Backups 250 + 251 + Services are automatically backed up nightly using restic to Backblaze B2. The `atelier-backup` CLI provides an interactive TUI for managing backups: 252 + 253 + ```bash 254 + atelier-backup # Interactive menu 255 + atelier-backup status # Show backup status 256 + atelier-backup restore # Restore wizard 257 + atelier-backup dr # Disaster recovery 258 + ``` 259 + 260 + See [modules/nixos/services/restic/README.md](modules/nixos/services/restic/README.md) for setup and usage. 244 261 245 262 ## some odd things 246 263
+30
machines/terebithia/default.nix
··· 137 137 file = ../../secrets/l4.age; 138 138 owner = "l4"; 139 139 }; 140 + "restic/env".file = ../../secrets/restic/env.age; 141 + "restic/repo".file = ../../secrets/restic/repo.age; 142 + "restic/password".file = ../../secrets/restic/password.age; 140 143 }; 141 144 142 145 environment.sessionVariables = { ··· 151 154 152 155 atelier = { 153 156 authentication.enable = true; 157 + backup.enable = true; 154 158 }; 155 159 156 160 networking = { ··· 373 377 }; 374 378 }; 375 379 380 + # Backup configuration for tangled services 381 + atelier.backup.services.knot = { 382 + paths = [ "/home/git" ]; # Git repositories managed by knot 383 + exclude = [ "*.log" ]; 384 + # Uses SQLite, stop before backup 385 + preBackup = "systemctl stop knot"; 386 + postBackup = "systemctl start knot"; 387 + }; 388 + 389 + atelier.backup.services.spindle = { 390 + paths = [ "/var/lib/spindle" ]; 391 + exclude = [ "*.log" "cache/*" ]; 392 + # Uses SQLite, stop before backup 393 + preBackup = "systemctl stop spindle"; 394 + postBackup = "systemctl start spindle"; 395 + }; 396 + 376 397 atelier.services.knot-sync = { 377 398 enable = true; 378 399 secretsFile = config.age.secrets.github-knot-sync.path; ··· 419 440 header_up X-Forwarded-For {remote} 420 441 } 421 442 ''; 443 + }; 444 + 445 + # Backup configuration for n8n 446 + atelier.backup.services.n8n = { 447 + paths = [ "/var/lib/n8n" ]; 448 + exclude = [ "*.log" "cache/*" ]; 449 + # n8n uses SQLite, stop before backup 450 + preBackup = "systemctl stop n8n"; 451 + postBackup = "systemctl start n8n"; 422 452 }; 423 453 424 454 boot.loader.systemd-boot.enable = true;
+284
modules/lib/mkService.nix
··· 1 + # mkService - Base service factory for atelier services 2 + # 3 + # Creates a standardized NixOS service module with: 4 + # - Common options (domain, port, dataDir, secrets, etc.) 5 + # - Systemd service with git-based deployment 6 + # - Caddy reverse proxy configuration 7 + # - Automatic backup integration via data declarations 8 + # 9 + # Usage in a service module: 10 + # let 11 + # mkService = import ../../lib/mkService.nix; 12 + # in 13 + # mkService { 14 + # name = "myapp"; 15 + # defaultPort = 3000; 16 + # extraOptions = { ... }; 17 + # extraConfig = cfg: { ... }; 18 + # } 19 + 20 + # This file is a function that takes service parameters and returns a NixOS module 21 + { 22 + # Service identity 23 + name, 24 + description ? "${name} service", 25 + defaultPort ? 3000, 26 + 27 + # Runtime configuration 28 + runtime ? "bun", # "bun" | "node" | "custom" 29 + entryPoint ? "src/index.ts", 30 + startCommand ? null, # Override the start command entirely 31 + 32 + # Additional options specific to this service 33 + extraOptions ? {}, 34 + 35 + # Additional config when service is enabled 36 + # Receives cfg (the service config) as argument 37 + extraConfig ? cfg: {}, 38 + }: 39 + 40 + # Return a proper NixOS module 41 + { config, lib, pkgs, ... }: 42 + 43 + let 44 + cfg = config.atelier.services.${name}; 45 + 46 + # Generate start command based on runtime 47 + defaultStartCommand = { 48 + bun = "${pkgs.unstable.bun}/bin/bun run ${entryPoint}"; 49 + node = "${pkgs.nodejs_20}/bin/node ${entryPoint}"; 50 + }.${runtime} or ""; 51 + 52 + finalStartCommand = if startCommand != null then startCommand else defaultStartCommand; 53 + 54 + in { 55 + options.atelier.services.${name} = { 56 + enable = lib.mkEnableOption description; 57 + 58 + domain = lib.mkOption { 59 + type = lib.types.str; 60 + description = "Domain to serve ${name} on"; 61 + }; 62 + 63 + port = lib.mkOption { 64 + type = lib.types.port; 65 + default = defaultPort; 66 + description = "Port to run ${name} on"; 67 + }; 68 + 69 + dataDir = lib.mkOption { 70 + type = lib.types.path; 71 + default = "/var/lib/${name}"; 72 + description = "Directory to store ${name} data"; 73 + }; 74 + 75 + secretsFile = lib.mkOption { 76 + type = lib.types.nullOr lib.types.path; 77 + default = null; 78 + description = "Path to agenix secrets file"; 79 + }; 80 + 81 + # Git-based deployment 82 + deploy = { 83 + enable = lib.mkEnableOption "Git-based deployment" // { default = true; }; 84 + 85 + repository = lib.mkOption { 86 + type = lib.types.nullOr lib.types.str; 87 + default = null; 88 + description = "Git repository URL for auto-deployment"; 89 + }; 90 + 91 + autoUpdate = lib.mkEnableOption "Automatically git pull on service restart"; 92 + 93 + branch = lib.mkOption { 94 + type = lib.types.str; 95 + default = "main"; 96 + description = "Git branch to deploy"; 97 + }; 98 + }; 99 + 100 + # Data declarations for automatic backup 101 + data = { 102 + sqlite = lib.mkOption { 103 + type = lib.types.nullOr lib.types.str; 104 + default = null; 105 + description = "Path to SQLite database (will checkpoint WAL and stop service for backup)"; 106 + example = "/var/lib/myapp/data/app.db"; 107 + }; 108 + 109 + postgres = lib.mkOption { 110 + type = lib.types.nullOr lib.types.str; 111 + default = null; 112 + description = "PostgreSQL database name (will use pg_dump for backup)"; 113 + }; 114 + 115 + files = lib.mkOption { 116 + type = lib.types.listOf lib.types.str; 117 + default = []; 118 + description = "Additional file paths to backup (no service interruption)"; 119 + example = [ "/var/lib/myapp/uploads" ]; 120 + }; 121 + 122 + exclude = lib.mkOption { 123 + type = lib.types.listOf lib.types.str; 124 + default = [ "*.log" "node_modules" ".git" "cache" "tmp" ]; 125 + description = "Glob patterns to exclude from backup"; 126 + }; 127 + }; 128 + 129 + # Caddy configuration 130 + caddy = { 131 + enable = lib.mkEnableOption "Caddy reverse proxy" // { default = true; }; 132 + 133 + extraConfig = lib.mkOption { 134 + type = lib.types.lines; 135 + default = ""; 136 + description = "Additional Caddy configuration"; 137 + }; 138 + 139 + rateLimit = { 140 + enable = lib.mkEnableOption "Rate limiting"; 141 + 142 + events = lib.mkOption { 143 + type = lib.types.int; 144 + default = 60; 145 + description = "Number of requests allowed per window"; 146 + }; 147 + 148 + window = lib.mkOption { 149 + type = lib.types.str; 150 + default = "1m"; 151 + description = "Time window for rate limiting"; 152 + }; 153 + }; 154 + }; 155 + 156 + # Environment variables (in addition to secretsFile) 157 + environment = lib.mkOption { 158 + type = lib.types.attrsOf lib.types.str; 159 + default = {}; 160 + description = "Additional environment variables"; 161 + }; 162 + } // extraOptions; 163 + 164 + config = lib.mkIf cfg.enable (lib.mkMerge [ 165 + # Base service configuration 166 + { 167 + # Create user and group 168 + users.groups.services = {}; 169 + 170 + users.users.${name} = { 171 + isSystemUser = true; 172 + group = name; 173 + extraGroups = [ "services" ]; 174 + home = cfg.dataDir; 175 + createHome = true; 176 + shell = pkgs.bash; 177 + }; 178 + 179 + users.groups.${name} = {}; 180 + 181 + # Allow service user to restart their own service 182 + security.sudo.extraRules = [ 183 + { 184 + users = [ name ]; 185 + commands = [ 186 + { 187 + command = "/run/current-system/sw/bin/systemctl restart ${name}.service"; 188 + options = [ "NOPASSWD" ]; 189 + } 190 + ]; 191 + } 192 + ]; 193 + 194 + # Systemd service 195 + systemd.services.${name} = { 196 + inherit description; 197 + wantedBy = [ "multi-user.target" ]; 198 + after = [ "network.target" ]; 199 + path = [ pkgs.git ]; 200 + 201 + preStart = lib.optionalString (cfg.deploy.enable && cfg.deploy.repository != null) '' 202 + # Clone repository if not present 203 + if [ ! -d ${cfg.dataDir}/app/.git ]; then 204 + ${pkgs.git}/bin/git clone -b ${cfg.deploy.branch} ${cfg.deploy.repository} ${cfg.dataDir}/app 205 + fi 206 + 207 + cd ${cfg.dataDir}/app 208 + '' + lib.optionalString (cfg.deploy.enable && cfg.deploy.autoUpdate) '' 209 + ${pkgs.git}/bin/git fetch origin 210 + ${pkgs.git}/bin/git reset --hard origin/${cfg.deploy.branch} 211 + '' + lib.optionalString (runtime == "bun") '' 212 + 213 + if [ -f package.json ]; then 214 + echo "Installing dependencies..." 215 + ${pkgs.unstable.bun}/bin/bun install 216 + fi 217 + '' + lib.optionalString (runtime == "node") '' 218 + 219 + if [ -f package.json ]; then 220 + echo "Installing dependencies..." 221 + ${pkgs.nodejs_20}/bin/npm ci --production 222 + fi 223 + ''; 224 + 225 + serviceConfig = { 226 + Type = "simple"; 227 + User = name; 228 + Group = name; 229 + WorkingDirectory = "${cfg.dataDir}/app"; 230 + EnvironmentFile = lib.mkIf (cfg.secretsFile != null) cfg.secretsFile; 231 + Environment = [ 232 + "NODE_ENV=production" 233 + "PORT=${toString cfg.port}" 234 + ] ++ (lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment); 235 + ExecStart = "${pkgs.bash}/bin/bash -c '${finalStartCommand}'"; 236 + Restart = "always"; 237 + RestartSec = "10s"; 238 + 239 + # Security hardening 240 + NoNewPrivileges = true; 241 + ProtectSystem = "strict"; 242 + ProtectHome = true; 243 + ReadWritePaths = [ cfg.dataDir ]; 244 + PrivateTmp = true; 245 + }; 246 + 247 + serviceConfig.ExecStartPre = [ 248 + "+${pkgs.writeShellScript "${name}-setup" '' 249 + mkdir -p ${cfg.dataDir}/app 250 + mkdir -p ${cfg.dataDir}/data 251 + chown -R ${name}:services ${cfg.dataDir} 252 + chmod -R g+rwX ${cfg.dataDir} 253 + ''}" 254 + ]; 255 + }; 256 + 257 + # Caddy reverse proxy 258 + services.caddy.virtualHosts.${cfg.domain} = lib.mkIf cfg.caddy.enable { 259 + extraConfig = '' 260 + tls { 261 + dns cloudflare {env.CLOUDFLARE_API_TOKEN} 262 + } 263 + 264 + ${lib.optionalString cfg.caddy.rateLimit.enable '' 265 + rate_limit { 266 + zone ${name}_limit { 267 + key {http.request.remote_ip} 268 + events ${toString cfg.caddy.rateLimit.events} 269 + window ${cfg.caddy.rateLimit.window} 270 + } 271 + } 272 + ''} 273 + 274 + ${cfg.caddy.extraConfig} 275 + 276 + reverse_proxy localhost:${toString cfg.port} 277 + ''; 278 + }; 279 + } 280 + 281 + # Extra config from the service module 282 + (extraConfig cfg) 283 + ]); 284 + }
+24
modules/nixos/services/battleship-arena.nix
··· 55 55 type = types.package; 56 56 description = "The battleship-arena package to use"; 57 57 }; 58 + 59 + backup = { 60 + enable = mkEnableOption "Enable backups for battleship-arena" // { default = true; }; 61 + 62 + paths = mkOption { 63 + type = types.listOf types.str; 64 + default = [ "/var/lib/battleship-arena" ]; 65 + description = "Paths to back up"; 66 + }; 67 + 68 + exclude = mkOption { 69 + type = types.listOf types.str; 70 + default = [ "*.log" ]; 71 + description = "Patterns to exclude from backup"; 72 + }; 73 + }; 58 74 }; 59 75 60 76 config = mkIf cfg.enable { ··· 158 174 ''; 159 175 160 176 networking.firewall.allowedTCPPorts = [ cfg.sshPort ]; 177 + 178 + # Register backup configuration 179 + atelier.backup.services.battleship-arena = mkIf cfg.backup.enable { 180 + inherit (cfg.backup) paths exclude; 181 + # Has SQLite database, stop before backup 182 + preBackup = "systemctl stop battleship-arena"; 183 + postBackup = "systemctl start battleship-arena"; 184 + }; 161 185 }; 162 186 }
+21 -122
modules/nixos/services/cachet.nix
··· 1 - { 2 - config, 3 - lib, 4 - pkgs, 5 - ... 6 - }: 1 + # Cachet - Slack emoji/profile cache service 2 + # 3 + # Uses the mkService base to provide standardized: 4 + # - Systemd service with git deployment 5 + # - Caddy reverse proxy 6 + # - Automatic SQLite backup with WAL checkpoint 7 + 7 8 let 8 - cfg = config.atelier.services.cachet; 9 + mkService = import ../../lib/mkService.nix; 9 10 in 10 - { 11 - options.atelier.services.cachet = { 12 - enable = lib.mkEnableOption "Cachet Slack emoji/profile cache"; 13 11 14 - domain = lib.mkOption { 15 - type = lib.types.str; 16 - description = "Domain to serve cachet on"; 17 - }; 18 - 19 - port = lib.mkOption { 20 - type = lib.types.port; 21 - default = 3000; 22 - description = "Port to run cachet on"; 23 - }; 24 - 25 - dataDir = lib.mkOption { 26 - type = lib.types.path; 27 - default = "/var/lib/cachet"; 28 - description = "Directory to store cachet data"; 29 - }; 30 - 31 - secretsFile = lib.mkOption { 32 - type = lib.types.path; 33 - description = "Path to secrets file containing SLACK_TOKEN, SLACK_SIGNING_SECRET, BEARER_TOKEN"; 34 - }; 35 - 36 - repository = lib.mkOption { 37 - type = lib.types.str; 38 - default = "https://github.com/taciturnaxolotl/cachet.git"; 39 - description = "Git repository URL (optional, for auto-deployment)"; 40 - }; 41 - 42 - autoUpdate = lib.mkEnableOption "Automatically git pull on service restart"; 43 - }; 44 - 45 - config = lib.mkIf cfg.enable { 46 - users.groups.services = { }; 47 - 48 - users.users.cachet = { 49 - isSystemUser = true; 50 - group = "cachet"; 51 - extraGroups = [ "services" ]; 52 - home = cfg.dataDir; 53 - createHome = true; 54 - shell = pkgs.bash; 55 - }; 56 - 57 - users.groups.cachet = { }; 12 + mkService { 13 + name = "cachet"; 14 + description = "Cachet Slack emoji/profile cache"; 15 + defaultPort = 3000; 16 + runtime = "bun"; 17 + entryPoint = "src/index.ts"; 58 18 59 - security.sudo.extraRules = [ 60 - { 61 - users = [ "cachet" ]; 62 - commands = [ 63 - { 64 - command = "/run/current-system/sw/bin/systemctl restart cachet.service"; 65 - options = [ "NOPASSWD" ]; 66 - } 67 - ]; 68 - } 19 + extraConfig = cfg: { 20 + # Set DATABASE_PATH environment variable 21 + systemd.services.cachet.serviceConfig.Environment = [ 22 + "DATABASE_PATH=${cfg.dataDir}/data/cachet.db" 69 23 ]; 70 24 71 - systemd.services.cachet = { 72 - description = "Cachet Slack emoji/profile cache"; 73 - wantedBy = [ "multi-user.target" ]; 74 - after = [ "network.target" ]; 75 - path = [ pkgs.git ]; 76 - 77 - preStart = '' 78 - if [ ! -d ${cfg.dataDir}/app/.git ]; then 79 - ${pkgs.git}/bin/git clone ${cfg.repository} ${cfg.dataDir}/app 80 - fi 81 - 82 - cd ${cfg.dataDir}/app 83 - '' + lib.optionalString cfg.autoUpdate '' 84 - ${pkgs.git}/bin/git pull 85 - '' + '' 86 - 87 - if [ ! -f src/index.ts ]; then 88 - echo "No code found at ${cfg.dataDir}/app/src/index.ts" 89 - exit 1 90 - fi 91 - 92 - echo "Installing dependencies..." 93 - ${pkgs.unstable.bun}/bin/bun install 94 - ''; 95 - 96 - serviceConfig = { 97 - Type = "simple"; 98 - User = "cachet"; 99 - Group = "cachet"; 100 - EnvironmentFile = cfg.secretsFile; 101 - Environment = [ 102 - "NODE_ENV=production" 103 - "PORT=${toString cfg.port}" 104 - "DATABASE_PATH=${cfg.dataDir}/data/cachet.db" 105 - ]; 106 - ExecStart = "${pkgs.bash}/bin/bash -c 'cd ${cfg.dataDir}/app && ${pkgs.unstable.bun}/bin/bun run src/index.ts'"; 107 - Restart = "always"; 108 - RestartSec = "10s"; 109 - }; 110 - 111 - serviceConfig.ExecStartPre = [ 112 - "+${pkgs.writeShellScript "cachet-setup" '' 113 - mkdir -p ${cfg.dataDir}/data 114 - mkdir -p ${cfg.dataDir}/app 115 - chown -R cachet:services ${cfg.dataDir} 116 - chmod -R g+rwX ${cfg.dataDir} 117 - ''}" 118 - ]; 119 - }; 120 - 121 - services.caddy.virtualHosts.${cfg.domain} = { 122 - extraConfig = '' 123 - tls { 124 - dns cloudflare {env.CLOUDFLARE_API_TOKEN} 125 - } 126 - 127 - reverse_proxy localhost:${toString cfg.port} 128 - ''; 25 + # Data declarations for automatic backup 26 + atelier.services.cachet.data = { 27 + sqlite = "${cfg.dataDir}/data/cachet.db"; 129 28 }; 130 29 }; 131 30 }
+22
modules/nixos/services/emojibot.nix
··· 50 50 }; 51 51 52 52 autoUpdate = lib.mkEnableOption "Automatically git pull on service restart"; 53 + 54 + backup = { 55 + enable = lib.mkEnableOption "Enable backups for emojibot" // { default = true; }; 56 + 57 + paths = lib.mkOption { 58 + type = lib.types.listOf lib.types.str; 59 + default = [ cfg.dataDir ]; 60 + description = "Paths to back up"; 61 + }; 62 + 63 + exclude = lib.mkOption { 64 + type = lib.types.listOf lib.types.str; 65 + default = [ "*.log" "app/.git" "app/node_modules" ]; 66 + description = "Patterns to exclude from backup"; 67 + }; 68 + }; 53 69 }; 54 70 55 71 config = lib.mkIf cfg.enable { ··· 134 150 135 151 reverse_proxy localhost:${toString cfg.port} 136 152 ''; 153 + }; 154 + 155 + # Register backup configuration 156 + atelier.backup.services.emojibot = lib.mkIf cfg.backup.enable { 157 + inherit (cfg.backup) paths exclude; 158 + # Stateless service, no pre/post hooks needed 137 159 }; 138 160 }; 139 161 }
+24
modules/nixos/services/hn-alerts.nix
··· 40 40 }; 41 41 42 42 autoUpdate = lib.mkEnableOption "Automatically git pull on service restart"; 43 + 44 + backup = { 45 + enable = lib.mkEnableOption "Enable backups for hn-alerts" // { default = true; }; 46 + 47 + paths = lib.mkOption { 48 + type = lib.types.listOf lib.types.str; 49 + default = [ cfg.dataDir ]; 50 + description = "Paths to back up"; 51 + }; 52 + 53 + exclude = lib.mkOption { 54 + type = lib.types.listOf lib.types.str; 55 + default = [ "*.log" "app/.git" "app/node_modules" ]; 56 + description = "Patterns to exclude from backup"; 57 + }; 58 + }; 43 59 }; 44 60 45 61 config = lib.mkIf cfg.enable { ··· 119 135 120 136 reverse_proxy localhost:${toString cfg.port} 121 137 ''; 138 + }; 139 + 140 + # Register backup configuration 141 + atelier.backup.services.hn-alerts = lib.mkIf cfg.backup.enable { 142 + inherit (cfg.backup) paths exclude; 143 + # Has database, stop before backup 144 + preBackup = "systemctl stop hn-alerts"; 145 + postBackup = "systemctl start hn-alerts"; 122 146 }; 123 147 }; 124 148 }
+24
modules/nixos/services/indiko.nix
··· 44 44 }; 45 45 46 46 autoUpdate = lib.mkEnableOption "Automatically git pull on service restart"; 47 + 48 + backup = { 49 + enable = lib.mkEnableOption "Enable backups for indiko" // { default = true; }; 50 + 51 + paths = lib.mkOption { 52 + type = lib.types.listOf lib.types.str; 53 + default = [ cfg.dataDir ]; 54 + description = "Paths to back up"; 55 + }; 56 + 57 + exclude = lib.mkOption { 58 + type = lib.types.listOf lib.types.str; 59 + default = [ "*.log" "app/.git" "app/node_modules" ]; 60 + description = "Patterns to exclude from backup"; 61 + }; 62 + }; 47 63 }; 48 64 49 65 config = lib.mkIf cfg.enable { ··· 164 180 reverse_proxy localhost:${toString cfg.port} 165 181 } 166 182 ''; 183 + }; 184 + 185 + # Register backup configuration 186 + atelier.backup.services.indiko = lib.mkIf cfg.backup.enable { 187 + inherit (cfg.backup) paths exclude; 188 + # Has SQLite database for sessions/tokens 189 + preBackup = "systemctl stop indiko"; 190 + postBackup = "systemctl start indiko"; 167 191 }; 168 192 }; 169 193 }
+22
modules/nixos/services/l4.nix
··· 50 50 }; 51 51 52 52 autoUpdate = lib.mkEnableOption "Automatically git pull on service restart"; 53 + 54 + backup = { 55 + enable = lib.mkEnableOption "Enable backups for l4" // { default = true; }; 56 + 57 + paths = lib.mkOption { 58 + type = lib.types.listOf lib.types.str; 59 + default = [ cfg.dataDir ]; 60 + description = "Paths to back up"; 61 + }; 62 + 63 + exclude = lib.mkOption { 64 + type = lib.types.listOf lib.types.str; 65 + default = [ "*.log" "app/.git" "app/node_modules" ]; 66 + description = "Patterns to exclude from backup"; 67 + }; 68 + }; 53 69 }; 54 70 55 71 config = lib.mkIf cfg.enable { ··· 136 152 137 153 reverse_proxy localhost:${toString cfg.port} 138 154 ''; 155 + }; 156 + 157 + # Register backup configuration 158 + atelier.backup.services.l4 = lib.mkIf cfg.backup.enable { 159 + inherit (cfg.backup) paths exclude; 160 + # Stateless service (images in R2), no pre/post hooks needed 139 161 }; 140 162 }; 141 163 }
+162
modules/nixos/services/restic/README.md
··· 1 + # Restic Backup System 2 + 3 + Per-service backup system using Restic and Backblaze B2, with automatic backup discovery from `mkService` data declarations. 4 + 5 + ## Quick Start 6 + 7 + ### 1. Create B2 Bucket 8 + 9 + 1. Go to [Backblaze B2 console](https://secure.backblaze.com/b2_buckets.htm) 10 + 2. Create a new bucket (e.g., `terebithia-backup`) 11 + 3. Create an application key with read/write access to this bucket 12 + 4. Note the Account ID, Application Key, and Bucket name 13 + 14 + ### 2. Create Agenix Secrets 15 + 16 + ```bash 17 + cd ~/dots/secrets 18 + mkdir -p restic 19 + 20 + # Repository encryption password 21 + echo "choose-a-strong-encryption-password" | agenix -e restic/password.age 22 + 23 + # B2 credentials 24 + cat > /tmp/restic-env << 'EOF' 25 + B2_ACCOUNT_ID="your-account-id" 26 + B2_ACCOUNT_KEY="your-application-key" 27 + EOF 28 + agenix -e restic/env.age < /tmp/restic-env 29 + rm /tmp/restic-env 30 + 31 + # Repository URL 32 + echo "b2:your-bucket-name:/" | agenix -e restic/repo.age 33 + ``` 34 + 35 + ### 3. Add Secrets to Machine Config 36 + 37 + ```nix 38 + age.secrets = { 39 + "restic/env".file = ../../secrets/restic/env.age; 40 + "restic/repo".file = ../../secrets/restic/repo.age; 41 + "restic/password".file = ../../secrets/restic/password.age; 42 + }; 43 + ``` 44 + 45 + ### 4. Enable Backup System 46 + 47 + ```nix 48 + atelier.backup.enable = true; 49 + ``` 50 + 51 + ### 5. Deploy and Verify 52 + 53 + ```bash 54 + deploy .#terebithia 55 + 56 + # Check timers are active 57 + ssh terebithia 'systemctl list-timers | grep restic' 58 + ``` 59 + 60 + ## Service Integration 61 + 62 + ### Automatic (mkService) 63 + 64 + Services using `mkService` with `data.*` declarations get automatic backup: 65 + 66 + ```nix 67 + # In your service module 68 + mkService { 69 + name = "myapp"; 70 + # ... 71 + extraConfig = cfg: { 72 + atelier.services.myapp.data = { 73 + sqlite = "${cfg.dataDir}/data/app.db"; # Auto WAL checkpoint + stop/start 74 + files = [ "${cfg.dataDir}/uploads" ]; # Just backed up, no hooks 75 + }; 76 + }; 77 + } 78 + ``` 79 + 80 + The backup system automatically: 81 + - Checkpoints SQLite WAL before backup 82 + - Stops the service during backup 83 + - Restarts after completion 84 + - Tags snapshots with `service:myapp` and `type:sqlite` 85 + 86 + ### Manual Registration 87 + 88 + For services not using `mkService`: 89 + 90 + ```nix 91 + atelier.backup.services.myservice = { 92 + paths = [ "/var/lib/myservice" ]; 93 + exclude = [ "*.log" "cache/*" ]; 94 + preBackup = "systemctl stop myservice"; 95 + postBackup = "systemctl start myservice"; 96 + }; 97 + ``` 98 + 99 + ## CLI Usage 100 + 101 + The `atelier-backup` command provides an interactive TUI: 102 + 103 + ```bash 104 + atelier-backup # Interactive menu 105 + atelier-backup status # Show backup status for all services 106 + atelier-backup list # Browse snapshots 107 + atelier-backup backup # Trigger manual backup 108 + atelier-backup restore # Interactive restore wizard 109 + atelier-backup dr # Disaster recovery mode 110 + ``` 111 + 112 + See `man atelier-backup` for full documentation. 113 + 114 + ## Backup Schedule 115 + 116 + - **Time**: 02:00 AM daily 117 + - **Random delay**: 0-2 hours (spreads load across services) 118 + - **Retention**: 119 + - Last 3 snapshots 120 + - 7 daily backups 121 + - 5 weekly backups 122 + - 12 monthly backups 123 + 124 + ## Disaster Recovery 125 + 126 + On a fresh NixOS install: 127 + 128 + 1. Rebuild from flake: `nixos-rebuild switch --flake .#hostname` 129 + 2. Run: `atelier-backup dr` 130 + 3. All services restored from latest snapshots 131 + 132 + A manifest at `/etc/atelier/backup-manifest.json` tracks all configured backups. 133 + 134 + ## Systemd Units 135 + 136 + Each service gets: 137 + - Timer: `restic-backups-<service>.timer` 138 + - Service: `restic-backups-<service>.service` 139 + 140 + ```bash 141 + # Check timer status 142 + systemctl list-timers | grep restic 143 + 144 + # View backup logs 145 + journalctl -u restic-backups-<service>.service 146 + 147 + # Manual backup trigger 148 + systemctl start restic-backups-<service>.service 149 + ``` 150 + 151 + ## Testing Backups 152 + 153 + Always verify backups work before relying on them: 154 + 155 + ```bash 156 + # Restore to /tmp for inspection 157 + atelier-backup restore 158 + # → Select service → Select snapshot → "Inspect (restore to /tmp)" 159 + 160 + # Check restored files 161 + ls -la /tmp/restore-myservice-*/ 162 + ```
+140
modules/nixos/services/restic/atelier-backup.1.md
··· 1 + % ATELIER-BACKUP(1) atelier-backup 1.0 2 + % Kieran Klukas 3 + % December 2024 4 + 5 + # NAME 6 + 7 + atelier-backup - interactive backup management for atelier services 8 + 9 + # SYNOPSIS 10 + 11 + **atelier-backup** [*COMMAND*] 12 + 13 + **atelier-backup** **status** 14 + 15 + **atelier-backup** **list** 16 + 17 + **atelier-backup** **backup** 18 + 19 + **atelier-backup** **restore** 20 + 21 + **atelier-backup** **dr** 22 + 23 + # DESCRIPTION 24 + 25 + **atelier-backup** is an interactive CLI for managing restic backups of atelier services. It provides a gum-powered TUI for browsing snapshots, triggering backups, restoring data, and performing disaster recovery. 26 + 27 + When run without arguments, an interactive menu is displayed. 28 + 29 + # COMMANDS 30 + 31 + **status** 32 + : Show the backup status for all configured services, including the date of the most recent snapshot. 33 + 34 + **list** 35 + : Interactively select a service and browse its available snapshots. 36 + 37 + **backup** 38 + : Trigger a manual backup for a selected service or all services. 39 + 40 + **restore** 41 + : Interactive restore wizard. Select a service, choose a snapshot, and restore either to /tmp for inspection or in-place (with service stop/start). 42 + 43 + **dr**, **disaster-recovery** 44 + : Full disaster recovery mode. Restores the latest snapshot for ALL services. Only use on a fresh NixOS install after rebuilding from the flake. 45 + 46 + # OPTIONS 47 + 48 + **-h**, **--help** 49 + : Display usage information and exit. 50 + 51 + # RESTORE MODES 52 + 53 + When restoring, you can choose between two modes: 54 + 55 + **Inspect (restore to /tmp)** 56 + : Restores the snapshot to /tmp/restore-SERVICE-SNAPSHOT for inspection. Safe and non-destructive. 57 + 58 + **In-place (DANGEROUS)** 59 + : Stops the service, restores directly to the original paths, and restarts the service. Use with caution. 60 + 61 + # DISASTER RECOVERY 62 + 63 + The **dr** command is designed for full server recovery: 64 + 65 + 1. Rebuild NixOS from the flake: `nixos-rebuild switch --flake .#hostname` 66 + 2. Run: `atelier-backup dr` 67 + 3. The CLI restores the latest snapshot for each service 68 + 4. Services are started automatically after restore 69 + 70 + A backup manifest is stored at **/etc/atelier/backup-manifest.json** containing metadata about all configured backups. 71 + 72 + # EXAMPLES 73 + 74 + Interactive menu: 75 + ``` 76 + $ atelier-backup 77 + ``` 78 + 79 + Check backup status for all services: 80 + ``` 81 + $ atelier-backup status 82 + ``` 83 + 84 + Browse snapshots for a service: 85 + ``` 86 + $ atelier-backup list 87 + ``` 88 + 89 + Trigger manual backup: 90 + ``` 91 + $ atelier-backup backup 92 + ``` 93 + 94 + Restore a service from backup: 95 + ``` 96 + $ atelier-backup restore 97 + ``` 98 + 99 + Full disaster recovery: 100 + ``` 101 + $ atelier-backup dr 102 + ``` 103 + 104 + # FILES 105 + 106 + **/etc/atelier/backup-manifest.json** 107 + : Generated manifest containing backup configuration for all services. 108 + 109 + **/run/agenix/restic/*** 110 + : Agenix-managed secrets for restic (env, repo, password). 111 + 112 + # BACKUP SCHEDULE 113 + 114 + Services are backed up nightly at 02:00 with a randomized delay of up to 2 hours to spread load. Backups are triggered via systemd timers: 115 + 116 + - `restic-backups-SERVICE.timer` 117 + - `restic-backups-SERVICE.service` 118 + 119 + # RETENTION POLICY 120 + 121 + Snapshots are retained according to: 122 + 123 + - Last 3 snapshots 124 + - 7 daily backups 125 + - 5 weekly backups 126 + - 12 monthly backups 127 + 128 + # SEE ALSO 129 + 130 + **restic**(1), **systemctl**(1) 131 + 132 + Restic documentation: https://restic.readthedocs.io/ 133 + 134 + # BUGS 135 + 136 + Report bugs at: https://github.com/taciturnaxolotl/dots/issues 137 + 138 + # AUTHORS 139 + 140 + Kieran Klukas <kierank@dunkirk.sh>
+341
modules/nixos/services/restic/cli.nix
··· 1 + # atelier-backup CLI - Interactive backup management with gum 2 + # 3 + # Commands: 4 + # atelier-backup - Interactive menu 5 + # atelier-backup status - Show backup status for all services 6 + # atelier-backup list - List snapshots (interactive service selection) 7 + # atelier-backup restore - Interactive restore wizard 8 + # atelier-backup backup - Trigger manual backup 9 + # atelier-backup dr - Disaster recovery mode 10 + 11 + { config, lib, pkgs, ... }: 12 + 13 + let 14 + cfg = config.atelier.backup; 15 + 16 + # Collect all services with backup data for the manifest 17 + atelierServices = lib.filterAttrs (name: svc: 18 + (svc.enable or false) && (svc.data or null) != null 19 + ) (config.atelier.services or {}); 20 + 21 + hasData = svc: 22 + (svc.data.sqlite or null) != null || 23 + (svc.data.postgres or null) != null || 24 + (svc.data.files or []) != []; 25 + 26 + servicesWithData = lib.filterAttrs (name: svc: hasData svc) atelierServices; 27 + 28 + # Also include manually registered backup services 29 + allBackupServices = (lib.attrNames cfg.services) ++ (lib.attrNames servicesWithData); 30 + 31 + # Generate manifest for disaster recovery 32 + backupManifest = pkgs.writeText "backup-manifest.json" (builtins.toJSON { 33 + version = 1; 34 + generated = "nixos-rebuild"; 35 + services = lib.mapAttrs (name: svc: { 36 + dataDir = svc.dataDir or "/var/lib/${name}"; 37 + data = { 38 + sqlite = svc.data.sqlite or null; 39 + postgres = svc.data.postgres or null; 40 + files = svc.data.files or []; 41 + exclude = svc.data.exclude or []; 42 + }; 43 + }) servicesWithData // lib.mapAttrs (name: backupCfg: { 44 + paths = backupCfg.paths; 45 + exclude = backupCfg.exclude or []; 46 + manual = true; 47 + }) cfg.services; 48 + }); 49 + 50 + backupCliScript = pkgs.writeShellScript "atelier-backup" '' 51 + set -e 52 + 53 + # Colors via gum 54 + style() { ${pkgs.gum}/bin/gum style "$@"; } 55 + confirm() { ${pkgs.gum}/bin/gum confirm "$@"; } 56 + choose() { ${pkgs.gum}/bin/gum choose "$@"; } 57 + input() { ${pkgs.gum}/bin/gum input "$@"; } 58 + spin() { ${pkgs.gum}/bin/gum spin "$@"; } 59 + 60 + # Restic wrapper with secrets 61 + restic_cmd() { 62 + ${pkgs.restic}/bin/restic \ 63 + --repository-file ${config.age.secrets."restic/repo".path} \ 64 + --password-file ${config.age.secrets."restic/password".path} \ 65 + "$@" 66 + } 67 + export -f restic_cmd 68 + export B2_ACCOUNT_ID=$(cat ${config.age.secrets."restic/env".path} | grep B2_ACCOUNT_ID | cut -d= -f2) 69 + export B2_ACCOUNT_KEY=$(cat ${config.age.secrets."restic/env".path} | grep B2_ACCOUNT_KEY | cut -d= -f2) 70 + 71 + # Available services 72 + SERVICES="${lib.concatStringsSep " " allBackupServices}" 73 + MANIFEST="${backupManifest}" 74 + 75 + cmd_status() { 76 + style --bold --foreground 212 "Backup Status" 77 + echo 78 + 79 + for svc in $SERVICES; do 80 + # Get latest snapshot for this service 81 + latest=$(restic_cmd snapshots --tag "service:$svc" --json --latest 1 2>/dev/null | ${pkgs.jq}/bin/jq -r '.[0] // empty') 82 + 83 + if [ -n "$latest" ]; then 84 + time=$(echo "$latest" | ${pkgs.jq}/bin/jq -r '.time' | cut -d'T' -f1) 85 + hostname=$(echo "$latest" | ${pkgs.jq}/bin/jq -r '.hostname') 86 + style --foreground 35 "✓ $svc" 87 + style --foreground 117 " Last backup: $time on $hostname" 88 + else 89 + style --foreground 214 "! $svc" 90 + style --foreground 117 " No backups found" 91 + fi 92 + done 93 + } 94 + 95 + cmd_list() { 96 + style --bold --foreground 212 "List Snapshots" 97 + echo 98 + 99 + # Let user pick a service 100 + svc=$(echo "$SERVICES" | tr ' ' '\n' | choose --header "Select service:") 101 + 102 + if [ -z "$svc" ]; then 103 + style --foreground 196 "No service selected" 104 + exit 1 105 + fi 106 + 107 + style --foreground 117 "Snapshots for $svc:" 108 + echo 109 + 110 + restic_cmd snapshots --tag "service:$svc" --compact 111 + } 112 + 113 + cmd_backup() { 114 + style --bold --foreground 212 "Manual Backup" 115 + echo 116 + 117 + # Let user pick a service or all 118 + svc=$(echo "all $SERVICES" | tr ' ' '\n' | choose --header "Select service to backup:") 119 + 120 + if [ -z "$svc" ]; then 121 + style --foreground 196 "No service selected" 122 + exit 1 123 + fi 124 + 125 + if [ "$svc" = "all" ]; then 126 + for s in $SERVICES; do 127 + style --foreground 117 "Backing up $s..." 128 + systemctl start "restic-backups-$s.service" || style --foreground 214 "! Failed to backup $s" 129 + done 130 + else 131 + style --foreground 117 "Backing up $svc..." 132 + systemctl start "restic-backups-$svc.service" 133 + fi 134 + 135 + style --foreground 35 "✓ Backup triggered" 136 + } 137 + 138 + cmd_restore() { 139 + style --bold --foreground 212 "Restore Wizard" 140 + echo 141 + 142 + # Pick service 143 + svc=$(echo "$SERVICES" | tr ' ' '\n' | choose --header "Select service to restore:") 144 + 145 + if [ -z "$svc" ]; then 146 + style --foreground 196 "No service selected" 147 + exit 1 148 + fi 149 + 150 + # List snapshots for selection 151 + style --foreground 117 "Fetching snapshots for $svc..." 152 + snapshots=$(restic_cmd snapshots --tag "service:$svc" --json 2>/dev/null) 153 + 154 + if [ "$(echo "$snapshots" | ${pkgs.jq}/bin/jq 'length')" = "0" ]; then 155 + style --foreground 196 "No snapshots found for $svc" 156 + exit 1 157 + fi 158 + 159 + # Format snapshots for selection 160 + snapshot_list=$(echo "$snapshots" | ${pkgs.jq}/bin/jq -r '.[] | "\(.short_id) - \(.time | split("T")[0]) - \(.paths | join(", "))"') 161 + 162 + selected=$(echo "$snapshot_list" | choose --header "Select snapshot:") 163 + snapshot_id=$(echo "$selected" | cut -d' ' -f1) 164 + 165 + if [ -z "$snapshot_id" ]; then 166 + style --foreground 196 "No snapshot selected" 167 + exit 1 168 + fi 169 + 170 + # Restore options 171 + restore_mode=$(choose --header "Restore mode:" "Inspect (restore to /tmp)" "In-place (DANGEROUS)") 172 + 173 + case "$restore_mode" in 174 + "Inspect"*) 175 + target="/tmp/restore-$svc-$snapshot_id" 176 + mkdir -p "$target" 177 + 178 + style --foreground 117 "Restoring to $target..." 179 + restic_cmd restore "$snapshot_id" --target "$target" 180 + 181 + style --foreground 35 "✓ Restored to $target" 182 + style --foreground 117 " Inspect files, then copy what you need" 183 + ;; 184 + 185 + "In-place"*) 186 + style --foreground 196 --bold "⚠ WARNING: This will overwrite existing data!" 187 + echo 188 + 189 + if ! confirm "Stop $svc and restore data?"; then 190 + style --foreground 214 "Restore cancelled" 191 + exit 0 192 + fi 193 + 194 + style --foreground 117 "Stopping $svc..." 195 + systemctl stop "$svc" 2>/dev/null || true 196 + 197 + style --foreground 117 "Restoring snapshot $snapshot_id..." 198 + restic_cmd restore "$snapshot_id" --target / 199 + 200 + style --foreground 117 "Starting $svc..." 201 + systemctl start "$svc" 202 + 203 + style --foreground 35 "✓ Restore complete" 204 + ;; 205 + esac 206 + } 207 + 208 + cmd_dr() { 209 + style --bold --foreground 196 "⚠ DISASTER RECOVERY MODE" 210 + echo 211 + style --foreground 214 "This will restore ALL services from backup." 212 + style --foreground 214 "Only use this on a fresh NixOS install." 213 + echo 214 + 215 + if ! confirm "Continue with full disaster recovery?"; then 216 + style --foreground 117 "Cancelled" 217 + exit 0 218 + fi 219 + 220 + style --foreground 117 "Reading backup manifest..." 221 + 222 + for svc in $SERVICES; do 223 + style --foreground 212 "Restoring $svc..." 224 + 225 + # Get latest snapshot 226 + snapshot_id=$(restic_cmd snapshots --tag "service:$svc" --json --latest 1 2>/dev/null | ${pkgs.jq}/bin/jq -r '.[0].short_id // empty') 227 + 228 + if [ -z "$snapshot_id" ]; then 229 + style --foreground 214 " ! No snapshots found, skipping" 230 + continue 231 + fi 232 + 233 + # Stop service if running 234 + systemctl stop "$svc" 2>/dev/null || true 235 + 236 + # Restore 237 + restic_cmd restore "$snapshot_id" --target / 238 + 239 + # Start service 240 + systemctl start "$svc" 2>/dev/null || true 241 + 242 + style --foreground 35 " ✓ Restored from $snapshot_id" 243 + done 244 + 245 + echo 246 + style --foreground 35 --bold "✓ Disaster recovery complete" 247 + } 248 + 249 + cmd_menu() { 250 + style --bold --foreground 212 "Atelier Backup" 251 + echo 252 + 253 + action=$(choose \ 254 + "Status - Show backup status" \ 255 + "List - Browse snapshots" \ 256 + "Backup - Trigger manual backup" \ 257 + "Restore - Restore from backup" \ 258 + "DR - Disaster recovery mode") 259 + 260 + case "$action" in 261 + Status*) cmd_status ;; 262 + List*) cmd_list ;; 263 + Backup*) cmd_backup ;; 264 + Restore*) cmd_restore ;; 265 + DR*) cmd_dr ;; 266 + *) exit 0 ;; 267 + esac 268 + } 269 + 270 + # Main 271 + case "''${1:-}" in 272 + status) cmd_status ;; 273 + list) cmd_list ;; 274 + backup) cmd_backup ;; 275 + restore) cmd_restore ;; 276 + dr|disaster-recovery) cmd_dr ;; 277 + --help|-h) 278 + echo "Usage: atelier-backup [command]" 279 + echo 280 + echo "Commands:" 281 + echo " status Show backup status for all services" 282 + echo " list List snapshots" 283 + echo " backup Trigger manual backup" 284 + echo " restore Interactive restore wizard" 285 + echo " dr Disaster recovery mode" 286 + echo 287 + echo "Run without arguments for interactive menu." 288 + ;; 289 + "") cmd_menu ;; 290 + *) 291 + style --foreground 196 "Unknown command: $1" 292 + exit 1 293 + ;; 294 + esac 295 + ''; 296 + 297 + backupCli = pkgs.stdenv.mkDerivation { 298 + pname = "atelier-backup"; 299 + version = "1.0.0"; 300 + 301 + dontUnpack = true; 302 + 303 + nativeBuildInputs = [ pkgs.installShellFiles pkgs.pandoc ]; 304 + 305 + manPageSrc = ./atelier-backup.1.md; 306 + bashCompletionSrc = ./completions/atelier-backup.bash; 307 + zshCompletionSrc = ./completions/atelier-backup.zsh; 308 + fishCompletionSrc = ./completions/atelier-backup.fish; 309 + 310 + buildPhase = '' 311 + ${pkgs.pandoc}/bin/pandoc -s -t man $manPageSrc -o atelier-backup.1 312 + ''; 313 + 314 + installPhase = '' 315 + mkdir -p $out/bin 316 + cp ${backupCliScript} $out/bin/atelier-backup 317 + chmod +x $out/bin/atelier-backup 318 + 319 + # Install man page 320 + installManPage atelier-backup.1 321 + 322 + # Install completions 323 + installShellCompletion --bash --name atelier-backup $bashCompletionSrc 324 + installShellCompletion --zsh --name _atelier-backup $zshCompletionSrc 325 + installShellCompletion --fish --name atelier-backup.fish $fishCompletionSrc 326 + ''; 327 + 328 + meta = with lib; { 329 + description = "Interactive backup management CLI for atelier services"; 330 + license = licenses.mit; 331 + }; 332 + }; 333 + 334 + in { 335 + config = lib.mkIf cfg.enable { 336 + environment.systemPackages = [ backupCli ]; 337 + 338 + # Store manifest for reference 339 + environment.etc."atelier/backup-manifest.json".source = backupManifest; 340 + }; 341 + }
+27
modules/nixos/services/restic/completions/atelier-backup.bash
··· 1 + # bash completion for atelier-backup 2 + 3 + _atelier_backup_completion() { 4 + local cur prev 5 + COMPREPLY=() 6 + cur="${COMP_WORDS[COMP_CWORD]}" 7 + prev="${COMP_WORDS[COMP_CWORD-1]}" 8 + 9 + # Main commands 10 + local commands="status list backup restore dr --help" 11 + 12 + # Complete flags 13 + if [[ ${cur} == -* ]]; then 14 + COMPREPLY=( $(compgen -W "--help -h" -- ${cur}) ) 15 + return 0 16 + fi 17 + 18 + # Complete commands as first argument 19 + if [[ ${COMP_CWORD} -eq 1 ]]; then 20 + COMPREPLY=( $(compgen -W "${commands}" -- ${cur}) ) 21 + return 0 22 + fi 23 + 24 + return 0 25 + } 26 + 27 + complete -F _atelier_backup_completion atelier-backup
+14
modules/nixos/services/restic/completions/atelier-backup.fish
··· 1 + # fish completion for atelier-backup 2 + 3 + # Disable file completion 4 + complete -c atelier-backup -f 5 + 6 + # Commands (first argument only) 7 + complete -c atelier-backup -n '__fish_is_first_token' -a 'status' -d 'Show backup status for all services' 8 + complete -c atelier-backup -n '__fish_is_first_token' -a 'list' -d 'List snapshots for a service' 9 + complete -c atelier-backup -n '__fish_is_first_token' -a 'backup' -d 'Trigger manual backup' 10 + complete -c atelier-backup -n '__fish_is_first_token' -a 'restore' -d 'Interactive restore wizard' 11 + complete -c atelier-backup -n '__fish_is_first_token' -a 'dr' -d 'Disaster recovery mode' 12 + 13 + # Flags 14 + complete -c atelier-backup -s h -l help -d 'Show help'
+30
modules/nixos/services/restic/completions/atelier-backup.zsh
··· 1 + #compdef atelier-backup 2 + 3 + _atelier-backup() { 4 + local curcontext="$curcontext" state line 5 + typeset -A opt_args 6 + 7 + _arguments -C \ 8 + '1: :->command' \ 9 + '--help[Show help]' \ 10 + '-h[Show help]' \ 11 + && return 0 12 + 13 + case $state in 14 + command) 15 + local -a commands 16 + commands=( 17 + 'status:Show backup status for all services' 18 + 'list:List snapshots for a service' 19 + 'backup:Trigger manual backup' 20 + 'restore:Interactive restore wizard' 21 + 'dr:Disaster recovery mode' 22 + ) 23 + _describe 'command' commands 24 + ;; 25 + esac 26 + 27 + return 0 28 + } 29 + 30 + _atelier-backup "$@"
+194
modules/nixos/services/restic/default.nix
··· 1 + { 2 + config, 3 + lib, 4 + pkgs, 5 + ... 6 + }: 7 + let 8 + cfg = config.atelier.backup; 9 + 10 + # Collect all atelier services that have data declarations 11 + atelierServices = lib.filterAttrs (name: svc: 12 + svc.enable or false && (svc.data or null) != null 13 + ) (config.atelier.services or {}); 14 + 15 + # Check if a service has any data to backup 16 + hasData = svc: 17 + (svc.data.sqlite or null) != null || 18 + (svc.data.postgres or null) != null || 19 + (svc.data.files or []) != []; 20 + 21 + # Collect services with data declarations 22 + servicesWithData = lib.filterAttrs (name: svc: hasData svc) atelierServices; 23 + 24 + # Also include manually registered services 25 + allBackups = cfg.services // (lib.mapAttrs mkAutoBackup servicesWithData); 26 + 27 + # Auto-generate backup config from service data declarations 28 + mkAutoBackup = name: svc: let 29 + data = svc.data; 30 + hasSqlite = data.sqlite or null != null; 31 + hasPostgres = data.postgres or null != null; 32 + 33 + # Collect all paths to backup 34 + paths = 35 + (lib.optional hasSqlite (builtins.dirOf data.sqlite)) ++ 36 + (data.files or []); 37 + 38 + # Pre-backup: handle database consistency 39 + preBackup = lib.concatStringsSep "\n" ( 40 + # SQLite: checkpoint WAL then stop service 41 + (lib.optional hasSqlite '' 42 + echo "Checkpointing SQLite WAL for ${name}..." 43 + ${pkgs.sqlite}/bin/sqlite3 "${data.sqlite}" "PRAGMA wal_checkpoint(TRUNCATE);" || true 44 + echo "Stopping ${name} for backup..." 45 + systemctl stop ${name} 46 + '') ++ 47 + # PostgreSQL: dump to file 48 + (lib.optional hasPostgres '' 49 + echo "Dumping PostgreSQL database ${data.postgres}..." 50 + ${pkgs.sudo}/bin/sudo -u postgres ${pkgs.postgresql}/bin/pg_dump ${data.postgres} > /tmp/${name}-pg-dump.sql 51 + '') ++ 52 + # If no database but service needs to be stopped (manual override possible) 53 + [] 54 + ); 55 + 56 + # Post-backup: restart service 57 + postBackup = lib.concatStringsSep "\n" ( 58 + (lib.optional hasSqlite '' 59 + echo "Restarting ${name} after backup..." 60 + systemctl start ${name} 61 + '') ++ 62 + (lib.optional hasPostgres '' 63 + rm -f /tmp/${name}-pg-dump.sql 64 + '') 65 + ); 66 + 67 + in { 68 + enable = true; 69 + inherit paths; 70 + exclude = data.exclude or [ "*.log" "node_modules" ".git" ]; 71 + tags = [ "service:${name}" ] ++ 72 + (lib.optional hasSqlite "type:sqlite") ++ 73 + (lib.optional hasPostgres "type:postgres"); 74 + preBackup = if preBackup != "" then preBackup else null; 75 + postBackup = if postBackup != "" then postBackup else null; 76 + }; 77 + 78 + # Create a restic backup job for a service 79 + mkBackupJob = name: serviceCfg: { 80 + inherit (serviceCfg) paths exclude; 81 + 82 + initialize = true; 83 + 84 + # Use secrets from agenix 85 + environmentFile = config.age.secrets."restic/env".path; 86 + repositoryFile = config.age.secrets."restic/repo".path; 87 + passwordFile = config.age.secrets."restic/password".path; 88 + 89 + # Tags for easier filtering during restore 90 + extraBackupArgs = 91 + (map (t: "--tag ${t}") (serviceCfg.tags or [ "service:${name}" ])) ++ 92 + [ "--verbose" ]; 93 + 94 + # Retention policy 95 + pruneOpts = [ 96 + "--keep-last 3" 97 + "--keep-daily 7" 98 + "--keep-weekly 5" 99 + "--keep-monthly 12" 100 + "--tag service:${name}" # Only prune this service's snapshots 101 + ]; 102 + 103 + # Backup schedule (nightly at 2 AM + random delay) 104 + timerConfig = { 105 + OnCalendar = "02:00"; 106 + RandomizedDelaySec = "2h"; 107 + Persistent = true; 108 + }; 109 + 110 + # Pre/post backup hooks for database consistency 111 + backupPrepareCommand = lib.optionalString (serviceCfg.preBackup or null != null) serviceCfg.preBackup; 112 + backupCleanupCommand = lib.optionalString (serviceCfg.postBackup or null != null) serviceCfg.postBackup; 113 + }; 114 + 115 + in 116 + { 117 + imports = [ ./cli.nix ]; 118 + 119 + options.atelier.backup = { 120 + enable = lib.mkEnableOption "Restic backup system"; 121 + 122 + # Manual service registration (for services not using mkService) 123 + services = lib.mkOption { 124 + type = lib.types.attrsOf ( 125 + lib.types.submodule { 126 + options = { 127 + enable = lib.mkOption { 128 + type = lib.types.bool; 129 + default = true; 130 + description = "Enable backups for this service"; 131 + }; 132 + 133 + paths = lib.mkOption { 134 + type = lib.types.listOf lib.types.str; 135 + description = "Paths to back up"; 136 + }; 137 + 138 + exclude = lib.mkOption { 139 + type = lib.types.listOf lib.types.str; 140 + default = [ "*.log" "node_modules" ".git" ]; 141 + description = "Glob patterns to exclude from backup"; 142 + }; 143 + 144 + tags = lib.mkOption { 145 + type = lib.types.listOf lib.types.str; 146 + default = []; 147 + description = "Tags to apply to snapshots"; 148 + }; 149 + 150 + preBackup = lib.mkOption { 151 + type = lib.types.nullOr lib.types.str; 152 + default = null; 153 + description = "Command to run before backup"; 154 + }; 155 + 156 + postBackup = lib.mkOption { 157 + type = lib.types.nullOr lib.types.str; 158 + default = null; 159 + description = "Command to run after backup"; 160 + }; 161 + }; 162 + } 163 + ); 164 + default = { }; 165 + description = "Per-service backup configurations (manual registration)"; 166 + }; 167 + }; 168 + 169 + config = lib.mkIf cfg.enable { 170 + # Ensure secrets are defined 171 + assertions = [ 172 + { 173 + assertion = config.age.secrets ? "restic/env"; 174 + message = "atelier.backup requires age.secrets.\"restic/env\" to be defined"; 175 + } 176 + { 177 + assertion = config.age.secrets ? "restic/repo"; 178 + message = "atelier.backup requires age.secrets.\"restic/repo\" to be defined"; 179 + } 180 + { 181 + assertion = config.age.secrets ? "restic/password"; 182 + message = "atelier.backup requires age.secrets.\"restic/password\" to be defined"; 183 + } 184 + ]; 185 + 186 + # Create restic backup jobs for each service (auto + manual) 187 + services.restic.backups = lib.mapAttrs mkBackupJob ( 188 + lib.filterAttrs (n: v: v.enable) allBackups 189 + ); 190 + 191 + # Add restic and sqlite to system packages for manual operations 192 + environment.systemPackages = [ pkgs.restic pkgs.sqlite ]; 193 + }; 194 + }
+13
secrets/restic/env.age
··· 1 + age-encryption.org/v1 2 + -> ssh-rsa DqcG0Q 3 + lT4PA3DsRI4Br6Dvx0walY/MPOUWzuhGdiGPRtzjFIqGYnlaJNX/SjT+gn+THM/7 4 + wKWWQCQ4bXw6EqoguRsW4fcONCUvxQj/by7+JnMsLNM+eu1xlZHVlyi9rEkbkE9K 5 + xtgxuKuXVhPEPeyaZjCMmaWu7QMXeepFnUrUGcqmhTdF5cYHr8z6qMCqyfm7nC27 6 + rkBqqs4z3hAXRjS3gDxBLJCbWMMKG7RGvO1E5wgMlQeLLdmbDOYnFBajJdt4KLMN 7 + EzjnWp/BcqCAbJc8OVuawV4EDEdoN74IG7CQteZ5DF77vExzESuMVpEhpKOq92ho 8 + JF7qwFHeQtKoHAFmg1xl4X0e+V2pEDhW1VXKAwLXgljUYS5QQKYhzciKT3yXN6o7 9 + AaLk407EUSMvXmVQ7isfUQrFMtFD2AXP1tE445vMu3u9SmoyBAiMg0AM1orPtAaW 10 + wYxPt3zOezMBFGjnLYfJ2uZzdPJXGk5NfhkKojw++ZhCJ0F6D7Nawpln6McYYEuX 11 + 12 + --- IgHl41EQ9rKfWh8SbOjEYhgK5NeHr9njPcLOYEx2OtI 13 + wNX����}�O�X)�Ч�N��e+I����p ]g����$�‹��C��}����Y���K��-p�V��z{�51�w��ﯹŽ��X��XFMXV�^K�yOf��7����[}�0�tt0$a}��h@/
secrets/restic/password.age

This is a binary file and will not be displayed.

+13
secrets/restic/repo.age
··· 1 + age-encryption.org/v1 2 + -> ssh-rsa DqcG0Q 3 + KQE6ZRRHyd/oWWkkl80O502xQwJQcmbn40ihhTsjzQm//fxKCjhOkRf0O7ZL3ZPn 4 + sdM+Qr+LphvyoqDvgJwKE4L0MIBVw46swql+wTBGSBLa/fZ676Bx1hKbBs6PvcPO 5 + z2Rr3KFh1vMWm3W8CdPVYnFPdW7PCQaXUTZSext+AvAcZ6f+rGnnoEq1RW05FMoD 6 + nYec+PJk9A7bQJ9OrLj4yDKj8GZbd0k5gox/HiWTNGy42fvdXkpjZ29BEPeMlITD 7 + 2msyzEg4ITNIzkyFr0h0WFMhL515CnHxXvTeO3sGKDYJPZ9iqpJlV5fvtz76v2h9 8 + ZvJYzY9EgRyhk6t6rDk98ZrtRcp2BPwfNHocdYdUHRO0wKoFubZaw2Ifr22aJHhb 9 + +9MiSzJ6qVc91Rtbc20Ib/dKh3OYdJvJ08pQrW8gDD4dd1hOx9kPREBKbaoqoQ7C 10 + CldNO49lGYO1U7bnohHF11LiwnFtnESdZJUtJBrldawDmY4ikntP2oTrPFsDMAz8 11 + 12 + --- fdcl8C/41l9nxbxvz7bwkSsfvFgmtXXQjyj72ijyK5c 13 + �)��+C�#m����I� RJ7R���W�h�x"Rx-^��q�`z������/��
+9
secrets/secrets.nix
··· 47 47 "l4.age".publicKeys = [ 48 48 kierank 49 49 ]; 50 + "restic/env.age".publicKeys = [ 51 + kierank 52 + ]; 53 + "restic/repo.age".publicKeys = [ 54 + kierank 55 + ]; 56 + "restic/password.age".publicKeys = [ 57 + kierank 58 + ]; 50 59 }