1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
|
#!/usr/bin/env bash
# Backup the home directory to a remote SSH location using rsync.
#
# Excludes large, regenerable, or already-replicated data (caches, trash,
# build artifacts, git checkouts, syncthing, media libraries, VM images,
# etc.). See the EXCLUDES array below for the full list.
#
# Usage:
# home-backup # Run the backup (real run)
# home-backup -n # Dry run (show what would transfer)
# home-backup -d user@host:/path/backup # Override destination
#
# The destination can also be set via the BACKUP_DEST environment variable.
set -euo pipefail
declare -r PROGRAM=${0##*/}
# Remote SSH destination. Defaults to the encrypted ZFS dataset on f2
# (zdata/enc/earth-backup, inherits aes-256-gcm encryption from zdata/enc).
# Override with the BACKUP_DEST env var or the -d flag.
declare DEST=${BACKUP_DEST:-"paul@f2.lan:/data/enc/earth-backup"}
# Source directory (trailing slash matters for rsync: copy contents of $HOME).
declare -r SRC="$HOME/"
declare DRY_RUN=""
# Paths/patterns to exclude from the backup. Patterns are relative to $HOME.
declare -ra EXCLUDES=(
# --- Explicitly excluded by request ---
"/git/"
"/syncthing/"
"/Pictures/"
"/Music/"
"/.gradle/"
"/.thunderbird/"
# --- Caches / trash / regenerable data ---
"/.cache/"
"/.local/share/Trash/"
"/.local/share/baloo/"
"/.local/share/containers/"
"/.local/share/gnome-boxes/"
"/.npm/"
"/go/"
"/.cargo/"
"/.bun/"
"/.android/"
"/.config/google-chrome/"
# --- Common regenerable junk anywhere in the tree ---
"node_modules/"
".cache/"
"*.tmp"
"/.var/app/*/cache/"
# --- Runtime / volatile ---
"/.gvfs/"
"/.dbus/"
)
log() {
printf '%s\n' "$*"
}
error() {
log "$*" >&2
}
usage() {
cat <<EOF
Usage: $PROGRAM [-n] [-d DEST]
-n Dry run (show what would be transferred, transfer nothing)
-d DEST Remote SSH destination (user@host:/path), overrides BACKUP_DEST
-h Show this help
Destination may also be set via the BACKUP_DEST environment variable
or by editing the DEST variable at the top of this script.
EOF
}
main() {
while getopts ':nd:h' opt; do
case "$opt" in
n) DRY_RUN="--dry-run" ;;
d) DEST="$OPTARG" ;;
h) usage; exit 0 ;;
:) error "Option -$OPTARG requires an argument."; usage; exit 2 ;;
?) error "Unknown option: -$OPTARG"; usage; exit 2 ;;
esac
done
if [[ "$DEST" == CHANGEME_* || -z "$DEST" ]]; then
error "No backup destination set."
error "Set BACKUP_DEST, pass -d user@host:/path, or edit DEST in $PROGRAM."
exit 1
fi
# Build the rsync exclude arguments.
local -a exclude_args=()
local pattern
for pattern in "${EXCLUDES[@]}"; do
exclude_args+=("--exclude=$pattern")
done
log "Source: $SRC"
log "Destination: $DEST"
[[ -n "$DRY_RUN" ]] && log "Mode: DRY RUN (no data transferred)"
log ""
# rsync flags:
# -a archive (recursive, perms, times, symlinks, etc.)
# -H preserve hard links
# -A preserve ACLs
# -X preserve extended attributes
# --numeric-ids don't map uid/gid by name on the remote
# --partial --progress resumable transfers with progress output
# --delete mirror: remove remote files that no longer exist locally
# --delete-excluded also remove previously-backed-up excluded paths
# --human-readable human-readable sizes
# -e ssh transport over SSH
rsync \
-aHAX \
--numeric-ids \
--partial --progress \
--delete --delete-excluded \
--human-readable \
${DRY_RUN:+$DRY_RUN} \
-e ssh \
"${exclude_args[@]}" \
"$SRC" \
"$DEST"
log ""
log "Backup complete."
}
main "$@"
|