Solution — Backup Script¶
Full reference implementation. Study this after attempting the project yourself.
backup.sh¶
#!/usr/bin/env bash
# =============================================================================
# backup.sh — rsync-based backup with rotation, compression, and verification
#
# Usage:
# backup.sh [OPTIONS] [COMMAND]
#
# Commands: backup (default) | list | verify [N] | restore [N]
# Options: -s SOURCE -d DEST -k KEEP -c (compress) -h
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEST_DIR="${HOME}/backups"
KEEP=7
COMPRESS=false
SOURCES=()
COMMAND="backup"
TIMESTAMP=$(date '+%Y-%m-%d_%H%M%S')
LOGFILE="" # set after DEST_DIR is parsed
# ── usage ─────────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS] [COMMAND]
Commands:
backup Run a backup (default)
restore [N] Restore backup N (1=newest)
list List available backups
verify [N] Verify backup N checksum
Options:
-s, --source DIR Source directory (repeatable)
-d, --dest DIR Destination (default: ~/backups)
-k, --keep N Backups to keep (default: 7)
-c, --compress Also create compressed tar.gz archive
-h, --help Show this help
EOF
}
# ── argument parsing ──────────────────────────────────────────────────────────
while [[ "${1:-}" =~ ^- ]]; do
case "$1" in
-s|--source) SOURCES+=("${2:?--source requires a path}"); shift ;;
-d|--dest) DEST_DIR="${2:?--dest requires a path}"; shift ;;
-k|--keep) KEEP="${2:?--keep requires a number}"; shift ;;
-c|--compress) COMPRESS=true ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
shift
done
COMMAND="${1:-backup}"
LOGFILE="${DEST_DIR}/backup.log"
# ── logging ───────────────────────────────────────────────────────────────────
_log() {
local level="$1"; shift
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*"
mkdir -p "$DEST_DIR"
echo "$msg" | tee -a "$LOGFILE"
}
log_info() { _log "INFO " "$@"; }
log_warn() { _log "WARN " "$@"; }
log_error() { _log "ERROR" "$@"; }
# ── helpers ───────────────────────────────────────────────────────────────────
nth_backup() {
find "$DEST_DIR" -maxdepth 1 -mindepth 1 -type d \
-name '[0-9][0-9][0-9][0-9]-*' | sort -r | sed -n "${1:-1}p"
}
# ── commands ──────────────────────────────────────────────────────────────────
do_backup() {
[[ "${#SOURCES[@]}" -eq 0 ]] && { echo "Error: at least one --source required" >&2; exit 1; }
local backup_dir="${DEST_DIR}/${TIMESTAMP}"
mkdir -p "$backup_dir"
log_info "=== Backup started → $backup_dir ==="
local failed=0
for src in "${SOURCES[@]}"; do
[[ -d "$src" ]] || { log_error "Source not found: $src"; (( failed++ )) || true; continue; }
local dest_name
dest_name="$(realpath "$src" | tr '/' '_' | sed 's/^_//')"
local dest="${backup_dir}/${dest_name}"
log_info "rsync: $src → $dest"
if rsync -a --delete -- "$src/" "$dest/"; then
log_info "rsync OK: $src"
else
log_error "rsync FAILED: $src (exit $?)"
(( failed++ )) || true
fi
done
if [[ "$COMPRESS" == true ]]; then
local archive="${backup_dir}.tar.gz"
log_info "compressing → $archive"
tar -czf "$archive" -C "$DEST_DIR" "$(basename "$backup_dir")/"
sha256sum "$archive" > "${archive}.sha256"
log_info "checksum: ${archive}.sha256"
fi
log_info "=== Backup done. Failed: $failed ==="
return $failed
}
rotate_backups() {
log_info "Rotating — keeping last $KEEP"
local dirs
mapfile -t dirs < <(find "$DEST_DIR" -maxdepth 1 -mindepth 1 -type d \
-name '[0-9][0-9][0-9][0-9]-*' | sort)
local count="${#dirs[@]}"
if (( count > KEEP )); then
local to_delete=$(( count - KEEP ))
for (( i=0; i<to_delete; i++ )); do
log_info "removing: ${dirs[$i]}"
rm -rf -- "${dirs[$i]}"
rm -f -- "${dirs[$i]}.tar.gz" "${dirs[$i]}.tar.gz.sha256"
done
fi
}
list_backups() {
echo "Available backups in $DEST_DIR:"
local i=1
while IFS= read -r dir; do
local size
size=$(du -sh "$dir" 2>/dev/null | cut -f1)
printf " %-3d %-22s (%s)\n" "$i" "$(basename "$dir")" "$size"
(( i++ ))
done < <(find "$DEST_DIR" -maxdepth 1 -mindepth 1 -type d \
-name '[0-9][0-9][0-9][0-9]-*' | sort -r)
}
verify_backup() {
local n="${1:-1}"
local dir
dir="$(nth_backup "$n")"
[[ -z "$dir" ]] && { echo "No backup #$n found" >&2; exit 1; }
echo "Verifying: $(basename "$dir")"
local archive="${dir}.tar.gz"
if [[ -f "${archive}.sha256" ]]; then
sha256sum --check "${archive}.sha256" && echo "Checksum OK" || { echo "Checksum FAILED" >&2; exit 1; }
else
local count
count=$(find "$dir" -type f | wc -l)
echo "rsync backup: $count files present"
fi
}
do_restore() {
local n="${1:-1}"
local dir
dir="$(nth_backup "$n")"
[[ -z "$dir" ]] && { echo "No backup #$n found" >&2; exit 1; }
echo "Restore from: $(basename "$dir")"
echo "WARNING: this will overwrite files in original source locations."
read -r -p "Continue? [y/N] " confirm
[[ "${confirm,,}" == "y" ]] || { echo "Aborted"; exit 0; }
for src_backup in "$dir"/*/; do
local restore_path="/${$(basename "$src_backup")//_/\/}"
log_info "restoring: $src_backup → $restore_path"
rsync -a -- "$src_backup/" "$restore_path/"
done
log_info "Restore complete"
}
# ── main ──────────────────────────────────────────────────────────────────────
main() {
case "$COMMAND" in
backup) do_backup; rotate_backups ;;
list) list_backups ;;
verify) verify_backup "${2:-1}" ;;
restore) do_restore "${2:-1}" ;;
*) echo "Unknown command: $COMMAND" >&2; usage; exit 1 ;;
esac
}
[[ "${BASH_SOURCE[0]}" == "$0" ]] && main "$@"
Sample Output¶
$ bash backup.sh --source ~/projects --dest ~/backups --keep 7 --compress
[2024-01-15 09:00:00] [INFO ] === Backup started → /home/user/backups/2024-01-15_090000 ===
[2024-01-15 09:00:00] [INFO ] rsync: /home/user/projects → .../2024-01-15_090000/home_user_projects
[2024-01-15 09:00:01] [INFO ] rsync OK: /home/user/projects
[2024-01-15 09:00:01] [INFO ] compressing → .../2024-01-15_090000.tar.gz
[2024-01-15 09:00:02] [INFO ] checksum: .../2024-01-15_090000.tar.gz.sha256
[2024-01-15 09:00:02] [INFO ] === Backup done. Failed: 0 ===
[2024-01-15 09:00:02] [INFO ] Rotating — keeping last 7
[2024-01-15 09:00:02] [INFO ] No rotation needed (3/7 slots used)
Cron Setup¶
# Run backup daily at 2 AM, keep 30 days of history
0 2 * * * /home/user/projects/backup-script/backup.sh \
--source /home/user/projects \
--dest /mnt/backup_drive/backups \
--keep 30 \
--compress
Key Design Decisions¶
| Decision | Why |
|---|---|
rsync -a --delete |
Mirrors source exactly; --delete removes files deleted from source |
mapfile -t dirs |
Safe array loading from find — avoids word-splitting on paths with spaces |
sort -r for listing |
Newest backup first; sed -n "${n}p" gives nth-newest |
| Separate rotation from backup | Rotation runs after backup so a crash mid-backup doesn't delete old copies |
sha256sum for verification |
Detects bit-rot and truncated archives; faster than re-extracting |