#!/usr/bin/env bash
# Daily backup for the homelab stack.
# 1. rsync --link-dest snapshot of /srv/data into /srv/snapshots/YYYY-MM-DD
# 2. rclone copy the fresh snapshot to B2
# 3. prune old snapshots (30 daily + 12 monthly) locally and on B2
# See mercemay.top/src/homelab-compose/ for context.
set -euo pipefail
SRC="${SRC:-/srv/data}"
SNAP_ROOT="${SNAP_ROOT:-/srv/snapshots}"
REMOTE="${REMOTE:-b2:homelab-backup}"
LOCK="/var/run/homelab-backup.lock"
LOG_TAG="homelab-backup"
log() {
printf '[%s] %s\n' "$(date -Iseconds)" "$*"
logger -t "$LOG_TAG" "$*" || true
}
die() {
log "ERROR: $*"
exit 1
}
[[ $EUID -eq 0 ]] || die "run as root (needs rsync --numeric-ids)"
exec 9>"$LOCK"
flock -n 9 || die "another backup already running"
today="$(date +%Y-%m-%d)"
dest="$SNAP_ROOT/$today"
if [[ -d "$dest" ]]; then
log "snapshot $today already exists, skipping rsync"
else
latest="$(ls -1d "$SNAP_ROOT"/????-??-?? 2>/dev/null | sort | tail -n1 || true)"
mkdir -p "$SNAP_ROOT"
link_opt=()
if [[ -n "$latest" && -d "$latest" ]]; then
link_opt=(--link-dest "$latest")
log "linking against $latest"
fi
log "rsync $SRC -> $dest"
rsync -aHAX --numeric-ids --delete \
--exclude '/cache/**' \
--exclude '/tmp/**' \
"${link_opt[@]}" \
"$SRC/" "$dest/"
fi
log "rclone copy $dest -> $REMOTE/$today"
rclone copy \
--transfers 4 \
--checkers 8 \
--fast-list \
--b2-hard-delete \
"$dest" "$REMOTE/$today"
prune_local() {
local keep_daily=30
local keep_monthly=12
mapfile -t all < <(ls -1d "$SNAP_ROOT"/????-??-?? 2>/dev/null | sort -r)
local kept=0
local months_seen=()
for snap in "${all[@]}"; do
local d; d="$(basename "$snap")"
local month="${d:0:7}"
if (( kept < keep_daily )); then
kept=$((kept+1))
continue
fi
if [[ "${d:8:2}" == "01" ]] && (( ${#months_seen[@]} < keep_monthly )); then
months_seen+=("$month")
continue
fi
log "pruning local $snap"
rm -rf --one-file-system "$snap"
done
}
prune_remote() {
local keep_daily=30
local keep_monthly=12
mapfile -t all < <(rclone lsjson --dirs-only "$REMOTE" | jq -r '.[].Name' | sort -r)
local kept=0
local months_seen=()
for d in "${all[@]}"; do
[[ "$d" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || continue
local month="${d:0:7}"
if (( kept < keep_daily )); then
kept=$((kept+1))
continue
fi
if [[ "${d:8:2}" == "01" ]] && (( ${#months_seen[@]} < keep_monthly )); then
months_seen+=("$month")
continue
fi
log "pruning remote $d"
rclone purge "$REMOTE/$d" || log "purge failed: $d"
done
}
prune_local
prune_remote
log "done; current snapshots:"
ls -1d "$SNAP_ROOT"/????-??-?? 2>/dev/null | sort -r | head -5 | while read -r s; do
log " $s"
done