Skip to content
Laviyx Learn

Try

Linux and Bash course menu

Bash Project: Write a Backup Script with Rotation

Build a real backup script in Bash: compressed archives with timestamps, argument checks, keeping only the newest copies, logging, a dry-run mode, and a cron schedule.

Intermediate8 min readUpdated
On this page

In this project, you will write a backup tool of the kind used on real servers. It will:

  • compress a folder into a dated archive;
  • skip folders such as node_modules;
  • keep only the newest copies;
  • log what it did;
  • offer a safe --dry-run mode.

Step 1: The script#

Create backup.sh:

Terminal
cat > backup.sh <<'EOF'
#!/usr/bin/env bash
#
# Usage: backup.sh [--dry-run] <source folder> <backup folder> [copies to keep]
set -euo pipefail

dry_run=false
if [[ "${1:-}" == "--dry-run" ]]; then
  dry_run=true
  shift
fi

usage() {
  echo "Usage: $0 [--dry-run] <source> <destination> [keep]" >&2
  exit 2
}

log() {
  echo "$*"
}

(( $# >= 2 )) || usage

source_dir="${1%/}"
dest_dir="${2%/}"
keep="${3:-3}"

[[ -d "$source_dir" ]] || { echo "Not a folder: $source_dir" >&2; exit 1; }
[[ "$keep" =~ ^[0-9]+$ ]] || { echo "keep must be a number" >&2; exit 2; }

name="$(basename "$source_dir")"
stamp="${BACKUP_STAMP:-$(date +%Y-%m-%d_%H%M%S)}"
archive="$dest_dir/$name-$stamp.tar.gz"

create_backup() {
  log "Creating $archive"
  if ! "$dry_run"; then
    mkdir -p "$dest_dir"
    tar -czf "$archive" --exclude='node_modules' --exclude='.git' \
      -C "$(dirname "$source_dir")" "$name"
  fi
}

rotate() {
  [[ -d "$dest_dir" ]] || return 0
  mapfile -t old < <(ls -1t "$dest_dir/$name"-*.tar.gz 2> /dev/null | tail -n +"$((keep + 1))")
  for file in "${old[@]}"; do
    log "Removing old backup $file"
    if ! "$dry_run"; then
      rm -- "$file"
    fi
  done
}

create_backup
rotate
log "Done"
EOF
chmod +x backup.sh

How it works#

  • "${1:-}" reads the first argument safely under set -u.
  • ${1%/} removes a trailing slash, so website/ and website behave the same.
  • (( $# >= 2 )) || usage stops if there are too few arguments.
  • tar -C changes folder first, so the archive holds website/... and not a full path.
  • ls -1t lists the newest first, and tail -n +4 gives everything after the first three.
  • rm -- means that no file name can be taken for an option.

BACKUP_STAMP lets us set the time for testing. Normally, the script uses the current time.

Step 2: Check the error handling#

Terminal
./backup.sh website
echo "Exit code: $?"
./backup.sh nothing backups
echo "Exit code: $?"
Output
Usage: ./backup.sh [--dry-run] <source> <destination> [keep]
Exit code: 2
Not a folder: nothing
Exit code: 1

Step 3: Run it#

Terminal
./backup.sh website backups
ls backups | wc -l
tar -tzf backups/*.tar.gz
Output
Creating backups/website-2026-11-05_062257.tar.gz
Done
1
website/
website/css/
website/css/main.css
website/index.html

node_modules was left out.

Step 4: Test rotation#

Create more backups, with made-up times, keeping two. sleep gives each file a different modification time:

Terminal
for day in 01 02 03; do
  BACKUP_STAMP="2026-11-$day" ./backup.sh website backups 2 | grep -c Removing || true
  sleep 1
done
ls backups
Output
0
1
1
website-2026-11-02.tar.gz
website-2026-11-03.tar.gz

The numbers show how many old backups each run removed. Only the two newest remain.

Step 5: Dry run#

--dry-run shows what would happen, and changes nothing:

Terminal
ls backups | wc -l
BACKUP_STAMP="2026-11-09" ./backup.sh --dry-run website backups 1
ls backups | wc -l
Output
2
Creating backups/website-2026-11-09.tar.gz
Removing old backup backups/website-2026-11-02.tar.gz
Done
2

Step 6: Restore test#

A backup you have never restored is only a hope. Test it:

Terminal
mkdir restore
tar -xzf "$(ls -1t backups/*.tar.gz | head -n 1)" -C restore
diff -r --exclude=node_modules website restore/website && echo "Backup matches"
Output
Backup matches

Step 7: Schedule it#

Add it to cron, with full paths and a log file. This runs at 02:30 every night:

Text
30 2 * * * /home/asha/backup.sh /var/www/site /backups 7 >> /home/asha/backup.log 2>&1

Extend the project#

  • Copy each archive to another server with rsync.
  • Send a message if the backup fails, using curl and a trap.
  • Add a --list option that shows existing backups.
  • Check for free disk space before starting.
  • Publish it on GitHub, with a good README.

Key takeaway#

Next: Project: Web Log Report.

Finished this lesson?