···2424│ ├── tacyon # rpi 5
2525│ └── terebithia # oracle cloud aarch64 server
2626├── modules
2727+│ ├── lib # shared nix utilities
2828+│ │ └── mkService.nix # base service factory
2729│ ├── home # home-manager modules
2830│ │ ├── aesthetics # theming and wallpapers
2931│ │ ├── apps # any app specific config
···3335│ │ └── hyprland
3436│ └── nixos # nixos modules
3537│ ├── apps # also app specific configs
3838+│ ├── services # self-hosted services with automatic backup
3939+│ │ └── restic # backup system (see modules/nixos/services/restic/README.md)
3640│ └── system # pam and my fancy wifi module for now
3741└── secrets # keep your grubby hands (or paws) off my data
3842···241245atuin login
242246atuin sync
243247```
248248+249249+## Backups
250250+251251+Services are automatically backed up nightly using restic to Backblaze B2. The `atelier-backup` CLI provides an interactive TUI for managing backups:
252252+253253+```bash
254254+atelier-backup # Interactive menu
255255+atelier-backup status # Show backup status
256256+atelier-backup restore # Restore wizard
257257+atelier-backup dr # Disaster recovery
258258+```
259259+260260+See [modules/nixos/services/restic/README.md](modules/nixos/services/restic/README.md) for setup and usage.
244261245262## some odd things
246263
···11+# Restic Backup System
22+33+Per-service backup system using Restic and Backblaze B2, with automatic backup discovery from `mkService` data declarations.
44+55+## Quick Start
66+77+### 1. Create B2 Bucket
88+99+1. Go to [Backblaze B2 console](https://secure.backblaze.com/b2_buckets.htm)
1010+2. Create a new bucket (e.g., `terebithia-backup`)
1111+3. Create an application key with read/write access to this bucket
1212+4. Note the Account ID, Application Key, and Bucket name
1313+1414+### 2. Create Agenix Secrets
1515+1616+```bash
1717+cd ~/dots/secrets
1818+mkdir -p restic
1919+2020+# Repository encryption password
2121+echo "choose-a-strong-encryption-password" | agenix -e restic/password.age
2222+2323+# B2 credentials
2424+cat > /tmp/restic-env << 'EOF'
2525+B2_ACCOUNT_ID="your-account-id"
2626+B2_ACCOUNT_KEY="your-application-key"
2727+EOF
2828+agenix -e restic/env.age < /tmp/restic-env
2929+rm /tmp/restic-env
3030+3131+# Repository URL
3232+echo "b2:your-bucket-name:/" | agenix -e restic/repo.age
3333+```
3434+3535+### 3. Add Secrets to Machine Config
3636+3737+```nix
3838+age.secrets = {
3939+ "restic/env".file = ../../secrets/restic/env.age;
4040+ "restic/repo".file = ../../secrets/restic/repo.age;
4141+ "restic/password".file = ../../secrets/restic/password.age;
4242+};
4343+```
4444+4545+### 4. Enable Backup System
4646+4747+```nix
4848+atelier.backup.enable = true;
4949+```
5050+5151+### 5. Deploy and Verify
5252+5353+```bash
5454+deploy .#terebithia
5555+5656+# Check timers are active
5757+ssh terebithia 'systemctl list-timers | grep restic'
5858+```
5959+6060+## Service Integration
6161+6262+### Automatic (mkService)
6363+6464+Services using `mkService` with `data.*` declarations get automatic backup:
6565+6666+```nix
6767+# In your service module
6868+mkService {
6969+ name = "myapp";
7070+ # ...
7171+ extraConfig = cfg: {
7272+ atelier.services.myapp.data = {
7373+ sqlite = "${cfg.dataDir}/data/app.db"; # Auto WAL checkpoint + stop/start
7474+ files = [ "${cfg.dataDir}/uploads" ]; # Just backed up, no hooks
7575+ };
7676+ };
7777+}
7878+```
7979+8080+The backup system automatically:
8181+- Checkpoints SQLite WAL before backup
8282+- Stops the service during backup
8383+- Restarts after completion
8484+- Tags snapshots with `service:myapp` and `type:sqlite`
8585+8686+### Manual Registration
8787+8888+For services not using `mkService`:
8989+9090+```nix
9191+atelier.backup.services.myservice = {
9292+ paths = [ "/var/lib/myservice" ];
9393+ exclude = [ "*.log" "cache/*" ];
9494+ preBackup = "systemctl stop myservice";
9595+ postBackup = "systemctl start myservice";
9696+};
9797+```
9898+9999+## CLI Usage
100100+101101+The `atelier-backup` command provides an interactive TUI:
102102+103103+```bash
104104+atelier-backup # Interactive menu
105105+atelier-backup status # Show backup status for all services
106106+atelier-backup list # Browse snapshots
107107+atelier-backup backup # Trigger manual backup
108108+atelier-backup restore # Interactive restore wizard
109109+atelier-backup dr # Disaster recovery mode
110110+```
111111+112112+See `man atelier-backup` for full documentation.
113113+114114+## Backup Schedule
115115+116116+- **Time**: 02:00 AM daily
117117+- **Random delay**: 0-2 hours (spreads load across services)
118118+- **Retention**:
119119+ - Last 3 snapshots
120120+ - 7 daily backups
121121+ - 5 weekly backups
122122+ - 12 monthly backups
123123+124124+## Disaster Recovery
125125+126126+On a fresh NixOS install:
127127+128128+1. Rebuild from flake: `nixos-rebuild switch --flake .#hostname`
129129+2. Run: `atelier-backup dr`
130130+3. All services restored from latest snapshots
131131+132132+A manifest at `/etc/atelier/backup-manifest.json` tracks all configured backups.
133133+134134+## Systemd Units
135135+136136+Each service gets:
137137+- Timer: `restic-backups-<service>.timer`
138138+- Service: `restic-backups-<service>.service`
139139+140140+```bash
141141+# Check timer status
142142+systemctl list-timers | grep restic
143143+144144+# View backup logs
145145+journalctl -u restic-backups-<service>.service
146146+147147+# Manual backup trigger
148148+systemctl start restic-backups-<service>.service
149149+```
150150+151151+## Testing Backups
152152+153153+Always verify backups work before relying on them:
154154+155155+```bash
156156+# Restore to /tmp for inspection
157157+atelier-backup restore
158158+# → Select service → Select snapshot → "Inspect (restore to /tmp)"
159159+160160+# Check restored files
161161+ls -la /tmp/restore-myservice-*/
162162+```
+140
modules/nixos/services/restic/atelier-backup.1.md
···11+% ATELIER-BACKUP(1) atelier-backup 1.0
22+% Kieran Klukas
33+% December 2024
44+55+# NAME
66+77+atelier-backup - interactive backup management for atelier services
88+99+# SYNOPSIS
1010+1111+**atelier-backup** [*COMMAND*]
1212+1313+**atelier-backup** **status**
1414+1515+**atelier-backup** **list**
1616+1717+**atelier-backup** **backup**
1818+1919+**atelier-backup** **restore**
2020+2121+**atelier-backup** **dr**
2222+2323+# DESCRIPTION
2424+2525+**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.
2626+2727+When run without arguments, an interactive menu is displayed.
2828+2929+# COMMANDS
3030+3131+**status**
3232+: Show the backup status for all configured services, including the date of the most recent snapshot.
3333+3434+**list**
3535+: Interactively select a service and browse its available snapshots.
3636+3737+**backup**
3838+: Trigger a manual backup for a selected service or all services.
3939+4040+**restore**
4141+: Interactive restore wizard. Select a service, choose a snapshot, and restore either to /tmp for inspection or in-place (with service stop/start).
4242+4343+**dr**, **disaster-recovery**
4444+: Full disaster recovery mode. Restores the latest snapshot for ALL services. Only use on a fresh NixOS install after rebuilding from the flake.
4545+4646+# OPTIONS
4747+4848+**-h**, **--help**
4949+: Display usage information and exit.
5050+5151+# RESTORE MODES
5252+5353+When restoring, you can choose between two modes:
5454+5555+**Inspect (restore to /tmp)**
5656+: Restores the snapshot to /tmp/restore-SERVICE-SNAPSHOT for inspection. Safe and non-destructive.
5757+5858+**In-place (DANGEROUS)**
5959+: Stops the service, restores directly to the original paths, and restarts the service. Use with caution.
6060+6161+# DISASTER RECOVERY
6262+6363+The **dr** command is designed for full server recovery:
6464+6565+1. Rebuild NixOS from the flake: `nixos-rebuild switch --flake .#hostname`
6666+2. Run: `atelier-backup dr`
6767+3. The CLI restores the latest snapshot for each service
6868+4. Services are started automatically after restore
6969+7070+A backup manifest is stored at **/etc/atelier/backup-manifest.json** containing metadata about all configured backups.
7171+7272+# EXAMPLES
7373+7474+Interactive menu:
7575+```
7676+$ atelier-backup
7777+```
7878+7979+Check backup status for all services:
8080+```
8181+$ atelier-backup status
8282+```
8383+8484+Browse snapshots for a service:
8585+```
8686+$ atelier-backup list
8787+```
8888+8989+Trigger manual backup:
9090+```
9191+$ atelier-backup backup
9292+```
9393+9494+Restore a service from backup:
9595+```
9696+$ atelier-backup restore
9797+```
9898+9999+Full disaster recovery:
100100+```
101101+$ atelier-backup dr
102102+```
103103+104104+# FILES
105105+106106+**/etc/atelier/backup-manifest.json**
107107+: Generated manifest containing backup configuration for all services.
108108+109109+**/run/agenix/restic/***
110110+: Agenix-managed secrets for restic (env, repo, password).
111111+112112+# BACKUP SCHEDULE
113113+114114+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:
115115+116116+- `restic-backups-SERVICE.timer`
117117+- `restic-backups-SERVICE.service`
118118+119119+# RETENTION POLICY
120120+121121+Snapshots are retained according to:
122122+123123+- Last 3 snapshots
124124+- 7 daily backups
125125+- 5 weekly backups
126126+- 12 monthly backups
127127+128128+# SEE ALSO
129129+130130+**restic**(1), **systemctl**(1)
131131+132132+Restic documentation: https://restic.readthedocs.io/
133133+134134+# BUGS
135135+136136+Report bugs at: https://github.com/taciturnaxolotl/dots/issues
137137+138138+# AUTHORS
139139+140140+Kieran Klukas <kierank@dunkirk.sh>
+341
modules/nixos/services/restic/cli.nix
···11+# atelier-backup CLI - Interactive backup management with gum
22+#
33+# Commands:
44+# atelier-backup - Interactive menu
55+# atelier-backup status - Show backup status for all services
66+# atelier-backup list - List snapshots (interactive service selection)
77+# atelier-backup restore - Interactive restore wizard
88+# atelier-backup backup - Trigger manual backup
99+# atelier-backup dr - Disaster recovery mode
1010+1111+{ config, lib, pkgs, ... }:
1212+1313+let
1414+ cfg = config.atelier.backup;
1515+1616+ # Collect all services with backup data for the manifest
1717+ atelierServices = lib.filterAttrs (name: svc:
1818+ (svc.enable or false) && (svc.data or null) != null
1919+ ) (config.atelier.services or {});
2020+2121+ hasData = svc:
2222+ (svc.data.sqlite or null) != null ||
2323+ (svc.data.postgres or null) != null ||
2424+ (svc.data.files or []) != [];
2525+2626+ servicesWithData = lib.filterAttrs (name: svc: hasData svc) atelierServices;
2727+2828+ # Also include manually registered backup services
2929+ allBackupServices = (lib.attrNames cfg.services) ++ (lib.attrNames servicesWithData);
3030+3131+ # Generate manifest for disaster recovery
3232+ backupManifest = pkgs.writeText "backup-manifest.json" (builtins.toJSON {
3333+ version = 1;
3434+ generated = "nixos-rebuild";
3535+ services = lib.mapAttrs (name: svc: {
3636+ dataDir = svc.dataDir or "/var/lib/${name}";
3737+ data = {
3838+ sqlite = svc.data.sqlite or null;
3939+ postgres = svc.data.postgres or null;
4040+ files = svc.data.files or [];
4141+ exclude = svc.data.exclude or [];
4242+ };
4343+ }) servicesWithData // lib.mapAttrs (name: backupCfg: {
4444+ paths = backupCfg.paths;
4545+ exclude = backupCfg.exclude or [];
4646+ manual = true;
4747+ }) cfg.services;
4848+ });
4949+5050+ backupCliScript = pkgs.writeShellScript "atelier-backup" ''
5151+ set -e
5252+5353+ # Colors via gum
5454+ style() { ${pkgs.gum}/bin/gum style "$@"; }
5555+ confirm() { ${pkgs.gum}/bin/gum confirm "$@"; }
5656+ choose() { ${pkgs.gum}/bin/gum choose "$@"; }
5757+ input() { ${pkgs.gum}/bin/gum input "$@"; }
5858+ spin() { ${pkgs.gum}/bin/gum spin "$@"; }
5959+6060+ # Restic wrapper with secrets
6161+ restic_cmd() {
6262+ ${pkgs.restic}/bin/restic \
6363+ --repository-file ${config.age.secrets."restic/repo".path} \
6464+ --password-file ${config.age.secrets."restic/password".path} \
6565+ "$@"
6666+ }
6767+ export -f restic_cmd
6868+ export B2_ACCOUNT_ID=$(cat ${config.age.secrets."restic/env".path} | grep B2_ACCOUNT_ID | cut -d= -f2)
6969+ export B2_ACCOUNT_KEY=$(cat ${config.age.secrets."restic/env".path} | grep B2_ACCOUNT_KEY | cut -d= -f2)
7070+7171+ # Available services
7272+ SERVICES="${lib.concatStringsSep " " allBackupServices}"
7373+ MANIFEST="${backupManifest}"
7474+7575+ cmd_status() {
7676+ style --bold --foreground 212 "Backup Status"
7777+ echo
7878+7979+ for svc in $SERVICES; do
8080+ # Get latest snapshot for this service
8181+ latest=$(restic_cmd snapshots --tag "service:$svc" --json --latest 1 2>/dev/null | ${pkgs.jq}/bin/jq -r '.[0] // empty')
8282+8383+ if [ -n "$latest" ]; then
8484+ time=$(echo "$latest" | ${pkgs.jq}/bin/jq -r '.time' | cut -d'T' -f1)
8585+ hostname=$(echo "$latest" | ${pkgs.jq}/bin/jq -r '.hostname')
8686+ style --foreground 35 "✓ $svc"
8787+ style --foreground 117 " Last backup: $time on $hostname"
8888+ else
8989+ style --foreground 214 "! $svc"
9090+ style --foreground 117 " No backups found"
9191+ fi
9292+ done
9393+ }
9494+9595+ cmd_list() {
9696+ style --bold --foreground 212 "List Snapshots"
9797+ echo
9898+9999+ # Let user pick a service
100100+ svc=$(echo "$SERVICES" | tr ' ' '\n' | choose --header "Select service:")
101101+102102+ if [ -z "$svc" ]; then
103103+ style --foreground 196 "No service selected"
104104+ exit 1
105105+ fi
106106+107107+ style --foreground 117 "Snapshots for $svc:"
108108+ echo
109109+110110+ restic_cmd snapshots --tag "service:$svc" --compact
111111+ }
112112+113113+ cmd_backup() {
114114+ style --bold --foreground 212 "Manual Backup"
115115+ echo
116116+117117+ # Let user pick a service or all
118118+ svc=$(echo "all $SERVICES" | tr ' ' '\n' | choose --header "Select service to backup:")
119119+120120+ if [ -z "$svc" ]; then
121121+ style --foreground 196 "No service selected"
122122+ exit 1
123123+ fi
124124+125125+ if [ "$svc" = "all" ]; then
126126+ for s in $SERVICES; do
127127+ style --foreground 117 "Backing up $s..."
128128+ systemctl start "restic-backups-$s.service" || style --foreground 214 "! Failed to backup $s"
129129+ done
130130+ else
131131+ style --foreground 117 "Backing up $svc..."
132132+ systemctl start "restic-backups-$svc.service"
133133+ fi
134134+135135+ style --foreground 35 "✓ Backup triggered"
136136+ }
137137+138138+ cmd_restore() {
139139+ style --bold --foreground 212 "Restore Wizard"
140140+ echo
141141+142142+ # Pick service
143143+ svc=$(echo "$SERVICES" | tr ' ' '\n' | choose --header "Select service to restore:")
144144+145145+ if [ -z "$svc" ]; then
146146+ style --foreground 196 "No service selected"
147147+ exit 1
148148+ fi
149149+150150+ # List snapshots for selection
151151+ style --foreground 117 "Fetching snapshots for $svc..."
152152+ snapshots=$(restic_cmd snapshots --tag "service:$svc" --json 2>/dev/null)
153153+154154+ if [ "$(echo "$snapshots" | ${pkgs.jq}/bin/jq 'length')" = "0" ]; then
155155+ style --foreground 196 "No snapshots found for $svc"
156156+ exit 1
157157+ fi
158158+159159+ # Format snapshots for selection
160160+ snapshot_list=$(echo "$snapshots" | ${pkgs.jq}/bin/jq -r '.[] | "\(.short_id) - \(.time | split("T")[0]) - \(.paths | join(", "))"')
161161+162162+ selected=$(echo "$snapshot_list" | choose --header "Select snapshot:")
163163+ snapshot_id=$(echo "$selected" | cut -d' ' -f1)
164164+165165+ if [ -z "$snapshot_id" ]; then
166166+ style --foreground 196 "No snapshot selected"
167167+ exit 1
168168+ fi
169169+170170+ # Restore options
171171+ restore_mode=$(choose --header "Restore mode:" "Inspect (restore to /tmp)" "In-place (DANGEROUS)")
172172+173173+ case "$restore_mode" in
174174+ "Inspect"*)
175175+ target="/tmp/restore-$svc-$snapshot_id"
176176+ mkdir -p "$target"
177177+178178+ style --foreground 117 "Restoring to $target..."
179179+ restic_cmd restore "$snapshot_id" --target "$target"
180180+181181+ style --foreground 35 "✓ Restored to $target"
182182+ style --foreground 117 " Inspect files, then copy what you need"
183183+ ;;
184184+185185+ "In-place"*)
186186+ style --foreground 196 --bold "⚠ WARNING: This will overwrite existing data!"
187187+ echo
188188+189189+ if ! confirm "Stop $svc and restore data?"; then
190190+ style --foreground 214 "Restore cancelled"
191191+ exit 0
192192+ fi
193193+194194+ style --foreground 117 "Stopping $svc..."
195195+ systemctl stop "$svc" 2>/dev/null || true
196196+197197+ style --foreground 117 "Restoring snapshot $snapshot_id..."
198198+ restic_cmd restore "$snapshot_id" --target /
199199+200200+ style --foreground 117 "Starting $svc..."
201201+ systemctl start "$svc"
202202+203203+ style --foreground 35 "✓ Restore complete"
204204+ ;;
205205+ esac
206206+ }
207207+208208+ cmd_dr() {
209209+ style --bold --foreground 196 "⚠ DISASTER RECOVERY MODE"
210210+ echo
211211+ style --foreground 214 "This will restore ALL services from backup."
212212+ style --foreground 214 "Only use this on a fresh NixOS install."
213213+ echo
214214+215215+ if ! confirm "Continue with full disaster recovery?"; then
216216+ style --foreground 117 "Cancelled"
217217+ exit 0
218218+ fi
219219+220220+ style --foreground 117 "Reading backup manifest..."
221221+222222+ for svc in $SERVICES; do
223223+ style --foreground 212 "Restoring $svc..."
224224+225225+ # Get latest snapshot
226226+ snapshot_id=$(restic_cmd snapshots --tag "service:$svc" --json --latest 1 2>/dev/null | ${pkgs.jq}/bin/jq -r '.[0].short_id // empty')
227227+228228+ if [ -z "$snapshot_id" ]; then
229229+ style --foreground 214 " ! No snapshots found, skipping"
230230+ continue
231231+ fi
232232+233233+ # Stop service if running
234234+ systemctl stop "$svc" 2>/dev/null || true
235235+236236+ # Restore
237237+ restic_cmd restore "$snapshot_id" --target /
238238+239239+ # Start service
240240+ systemctl start "$svc" 2>/dev/null || true
241241+242242+ style --foreground 35 " ✓ Restored from $snapshot_id"
243243+ done
244244+245245+ echo
246246+ style --foreground 35 --bold "✓ Disaster recovery complete"
247247+ }
248248+249249+ cmd_menu() {
250250+ style --bold --foreground 212 "Atelier Backup"
251251+ echo
252252+253253+ action=$(choose \
254254+ "Status - Show backup status" \
255255+ "List - Browse snapshots" \
256256+ "Backup - Trigger manual backup" \
257257+ "Restore - Restore from backup" \
258258+ "DR - Disaster recovery mode")
259259+260260+ case "$action" in
261261+ Status*) cmd_status ;;
262262+ List*) cmd_list ;;
263263+ Backup*) cmd_backup ;;
264264+ Restore*) cmd_restore ;;
265265+ DR*) cmd_dr ;;
266266+ *) exit 0 ;;
267267+ esac
268268+ }
269269+270270+ # Main
271271+ case "''${1:-}" in
272272+ status) cmd_status ;;
273273+ list) cmd_list ;;
274274+ backup) cmd_backup ;;
275275+ restore) cmd_restore ;;
276276+ dr|disaster-recovery) cmd_dr ;;
277277+ --help|-h)
278278+ echo "Usage: atelier-backup [command]"
279279+ echo
280280+ echo "Commands:"
281281+ echo " status Show backup status for all services"
282282+ echo " list List snapshots"
283283+ echo " backup Trigger manual backup"
284284+ echo " restore Interactive restore wizard"
285285+ echo " dr Disaster recovery mode"
286286+ echo
287287+ echo "Run without arguments for interactive menu."
288288+ ;;
289289+ "") cmd_menu ;;
290290+ *)
291291+ style --foreground 196 "Unknown command: $1"
292292+ exit 1
293293+ ;;
294294+ esac
295295+ '';
296296+297297+ backupCli = pkgs.stdenv.mkDerivation {
298298+ pname = "atelier-backup";
299299+ version = "1.0.0";
300300+301301+ dontUnpack = true;
302302+303303+ nativeBuildInputs = [ pkgs.installShellFiles pkgs.pandoc ];
304304+305305+ manPageSrc = ./atelier-backup.1.md;
306306+ bashCompletionSrc = ./completions/atelier-backup.bash;
307307+ zshCompletionSrc = ./completions/atelier-backup.zsh;
308308+ fishCompletionSrc = ./completions/atelier-backup.fish;
309309+310310+ buildPhase = ''
311311+ ${pkgs.pandoc}/bin/pandoc -s -t man $manPageSrc -o atelier-backup.1
312312+ '';
313313+314314+ installPhase = ''
315315+ mkdir -p $out/bin
316316+ cp ${backupCliScript} $out/bin/atelier-backup
317317+ chmod +x $out/bin/atelier-backup
318318+319319+ # Install man page
320320+ installManPage atelier-backup.1
321321+322322+ # Install completions
323323+ installShellCompletion --bash --name atelier-backup $bashCompletionSrc
324324+ installShellCompletion --zsh --name _atelier-backup $zshCompletionSrc
325325+ installShellCompletion --fish --name atelier-backup.fish $fishCompletionSrc
326326+ '';
327327+328328+ meta = with lib; {
329329+ description = "Interactive backup management CLI for atelier services";
330330+ license = licenses.mit;
331331+ };
332332+ };
333333+334334+in {
335335+ config = lib.mkIf cfg.enable {
336336+ environment.systemPackages = [ backupCli ];
337337+338338+ # Store manifest for reference
339339+ environment.etc."atelier/backup-manifest.json".source = backupManifest;
340340+ };
341341+}