Skip to content

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

enhancements | overview