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.
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-runmode.
Step 1: The script#
Create backup.sh:
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.shHow it works#
"${1:-}"reads the first argument safely underset -u.${1%/}removes a trailing slash, sowebsite/andwebsitebehave the same.(( $# >= 2 )) || usagestops if there are too few arguments.tar -Cchanges folder first, so the archive holdswebsite/...and not a full path.ls -1tlists the newest first, andtail -n +4gives 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#
./backup.sh website
echo "Exit code: $?"
./backup.sh nothing backups
echo "Exit code: $?"Usage: ./backup.sh [--dry-run] <source> <destination> [keep]
Exit code: 2
Not a folder: nothing
Exit code: 1Step 3: Run it#
./backup.sh website backups
ls backups | wc -l
tar -tzf backups/*.tar.gzCreating backups/website-2026-11-05_062257.tar.gz
Done
1
website/
website/css/
website/css/main.css
website/index.htmlnode_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:
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 backups0
1
1
website-2026-11-02.tar.gz
website-2026-11-03.tar.gzThe 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:
ls backups | wc -l
BACKUP_STAMP="2026-11-09" ./backup.sh --dry-run website backups 1
ls backups | wc -l2
Creating backups/website-2026-11-09.tar.gz
Removing old backup backups/website-2026-11-02.tar.gz
Done
2Step 6: Restore test#
A backup you have never restored is only a hope. Test it:
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"Backup matchesStep 7: Schedule it#
Add it to cron, with full paths and a log file. This runs at 02:30 every night:
30 2 * * * /home/asha/backup.sh /var/www/site /backups 7 >> /home/asha/backup.log 2>&1Extend the project#
- Copy each archive to another server with rsync.
- Send a message if the backup fails, using
curland atrap. - Add a
--listoption 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?