Project: Backup Script
Every piece covered throughout the module — Script Basics, Variables and Arguments, Conditions and Loops, Functions, Exit Codes and Error Handling — now comes together in one real task: a backup script that archives a directory regularly, with a date stamp, automatically cleans up old copies, and reports errors with a clear exit code. This is the first of the module's three required practical projects.
Goal and End Result
The script compresses and archives a given source directory with tar, adds a date to the archive name, logs success or failure, and automatically deletes archives older than a configured count. By the end of the project, backup.sh will be a script that validates its arguments, exits with the correct code on error, and can run unattended via cron.
Starting State
A simple environment is enough for this lab — no separate VM is needed, but it's recommended to work in test directories kept separate from real system directories:
mkdir -p ~/lab/backup-project/{data,backups}
cd ~/lab/backup-project
printf 'user data\n' > data/notes.txt
mkdir -p data/reports
printf 'report-2026-07\n' > data/reports/report.txt
Requirements
- Bash 5.x (or 4.x),
tar,gzip,find,date— installed by default on Ubuntu Server LTS; - write access to the source and backup directories;
- optional:
cron— for running the script on a schedule (covered in cron Basics).
Safety and Snapshot Note
The script deletes old files via find ... -delete — an irreversible action. The first time you try this project:
- point
BACKUP_DIRat a test directory like~/lab/backup-project/backupsabove, not a real backup directory; - first test the deletion line as a listing only, without
-delete(shown below); - only run the script against real data directories once you've confirmed the result is exactly as expected.
Building It Step by Step
Step 1: the script skeleton and argument validation
#!/bin/bash
set -euo pipefail
# backup.sh — archives a directory with a date stamp and cleans up old copies
# Usage: ./backup.sh <source_directory> <backup_directory> [days_to_keep]
SOURCE_DIR="${1:-}"
BACKUP_DIR="${2:-}"
RETENTION_DAYS="${3:-7}"
LOG_FILE="${BACKUP_DIR}/backup.log"
log() {
# Write to stderr (not stdout) so log lines never pollute a value
# captured with $(...) — see the note under Step 3.
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" >&2
}
if [ -z "$SOURCE_DIR" ] || [ -z "$BACKUP_DIR" ]; then
echo "Error: not enough arguments." >&2
echo "Usage: $0 <source_directory> <backup_directory> [days_to_keep]" >&2
exit 2
fi
if [ ! -d "$SOURCE_DIR" ]; then
echo "Error: source directory not found: $SOURCE_DIR" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
Verification:
Expected result:
Error: not enough arguments.
Usage: ./backup.sh <source_directory> <backup_directory> [days_to_keep]
Step 2: the archiving function
create_backup() {
local timestamp
timestamp=$(date +%Y%m%d-%H%M%S)
local archive_name="${BACKUP_DIR}/$(basename "$SOURCE_DIR")-${timestamp}.tar.gz"
if tar -czf "$archive_name" -C "$(dirname "$SOURCE_DIR")" "$(basename "$SOURCE_DIR")"; then
log "Backup successful: $archive_name"
return 0
else
log "ERROR: archiving failed"
return 1
fi
}
-C "$(dirname "$SOURCE_DIR")" tells tar to change into the source directory's parent directory before archiving; this ensures only the directory name itself (data/...), not an absolute path (/home/user/data/...), is stored inside the archive — a common practice so paths don't get confused when the archive is extracted somewhere else.
Add a call to this function at the end of the main script:
Verification:
./backup.sh ~/lab/backup-project/data ~/lab/backup-project/backups
ls -lh ~/lab/backup-project/backups
Expected result (date and size vary by system):
[2026-07-26 14:10:03] Backup successful: /home/<username>/lab/backup-project/backups/data-20260726-141003.tar.gz
-rw-r--r-- 1 <username> <username> 412 Jul 26 14:10 data-20260726-141003.tar.gz
-rw-r--r-- 1 <username> <username> 63 Jul 26 14:10 backup.log
Step 3: verifying archive integrity
Creating an archive doesn't guarantee it can be read back without errors (a full disk or an interruption can corrupt it). tar -tzf checks the archive's integrity by reading its listing, without extracting it:
verify_backup() {
local archive_name="$1"
if tar -tzf "$archive_name" > /dev/null 2>&1; then
log "Verification OK: $archive_name is intact"
return 0
else
log "ERROR: $archive_name is corrupted or incomplete"
return 1
fi
}
Update create_backup to return the archive name, then verify it:
create_backup() {
local timestamp
timestamp=$(date +%Y%m%d-%H%M%S)
local archive_name="${BACKUP_DIR}/$(basename "$SOURCE_DIR")-${timestamp}.tar.gz"
if tar -czf "$archive_name" -C "$(dirname "$SOURCE_DIR")" "$(basename "$SOURCE_DIR")"; then
log "Backup successful: $archive_name"
echo "$archive_name"
return 0
else
log "ERROR: archiving failed"
return 1
fi
}
archive_path=$(create_backup)
verify_backup "$archive_path"
Warning
archive_path=$(create_backup) captures only what the function prints to stdout — which must be the archive path and nothing else. That's precisely why log() above sends its output to stderr (>&2): if log wrote to stdout, its [timestamp] Backup successful: ... line would be captured together with the path, and archive_path would become a two-line string that verify_backup (and later tar -tzf) can't open. A function that returns a value through command substitution must keep its stdout clean — send every log/diagnostic line to stderr. This is the same "value on stdout, messages on stderr" rule from Exit Codes and Error Handling, applied to functions.
Verification:
./backup.sh ~/lab/backup-project/data ~/lab/backup-project/backups
tail -n 3 ~/lab/backup-project/backups/backup.log
Expected result:
[2026-07-26 14:15:41] Backup successful: /home/<username>/lab/backup-project/backups/data-20260726-141541.tar.gz
[2026-07-26 14:15:41] Verification OK: /home/<username>/lab/backup-project/backups/data-20260726-141541.tar.gz is intact
Step 4: cleaning up old archives (list first, then delete)
The most dangerous part — which is why it's tested first as a listing only, not with -delete:
This command deletes nothing — it only shows .tar.gz files older than RETENTION_DAYS. Only once you've confirmed the result is correct should the cleanup function be added:
cleanup_old_backups() {
local deleted_count
deleted_count=$(find "$BACKUP_DIR" -name "*.tar.gz" -mtime "+${RETENTION_DAYS}" -print -delete | wc -l)
if [ "$deleted_count" -gt 0 ]; then
log "Cleaned up: $deleted_count archive(s) older than $RETENTION_DAYS days removed"
else
log "Cleanup: no old archives found"
fi
}
Add the function to the main flow:
Verification (artificially creating an old file for the test):
touch -d "10 days ago" ~/lab/backup-project/backups/old-test.tar.gz
./backup.sh ~/lab/backup-project/data ~/lab/backup-project/backups 7
ls ~/lab/backup-project/backups
Expected result: old-test.tar.gz should no longer appear in the listing, since it was marked as older than 7 days, and backup.log will have an entry noting the deletion.
Danger
-mtime +N checks a file's last-modified time, not its creation time. If archives were copied in from elsewhere, their mtime may have kept the original file's timestamp — causing them to be deleted sooner or later than expected. Always review the result of find ... -mtime +N separately before it's paired with -delete.
The Complete Script
#!/bin/bash
set -euo pipefail
# backup.sh — archives a directory with a date stamp, verifies its integrity, and cleans up old copies
# Usage: ./backup.sh <source_directory> <backup_directory> [days_to_keep]
SOURCE_DIR="${1:-}"
BACKUP_DIR="${2:-}"
RETENTION_DAYS="${3:-7}"
LOG_FILE="${BACKUP_DIR}/backup.log"
log() {
# Write to stderr (not stdout) so log lines never pollute a value
# captured with $(...) — see the note under Step 3.
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" >&2
}
create_backup() {
local timestamp
timestamp=$(date +%Y%m%d-%H%M%S)
local archive_name="${BACKUP_DIR}/$(basename "$SOURCE_DIR")-${timestamp}.tar.gz"
if tar -czf "$archive_name" -C "$(dirname "$SOURCE_DIR")" "$(basename "$SOURCE_DIR")"; then
log "Backup successful: $archive_name"
echo "$archive_name"
return 0
else
log "ERROR: archiving failed"
return 1
fi
}
verify_backup() {
local archive_name="$1"
if tar -tzf "$archive_name" > /dev/null 2>&1; then
log "Verification OK: $archive_name is intact"
return 0
else
log "ERROR: $archive_name is corrupted or incomplete"
return 1
fi
}
cleanup_old_backups() {
local deleted_count
deleted_count=$(find "$BACKUP_DIR" -name "*.tar.gz" -mtime "+${RETENTION_DAYS}" -print -delete | wc -l)
if [ "$deleted_count" -gt 0 ]; then
log "Cleaned up: $deleted_count archive(s) older than $RETENTION_DAYS days removed"
else
log "Cleanup: no old archives found"
fi
}
if [ -z "$SOURCE_DIR" ] || [ -z "$BACKUP_DIR" ]; then
echo "Error: not enough arguments." >&2
echo "Usage: $0 <source_directory> <backup_directory> [days_to_keep]" >&2
exit 2
fi
if [ ! -d "$SOURCE_DIR" ]; then
echo "Error: source directory not found: $SOURCE_DIR" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
archive_path=$(create_backup)
verify_backup "$archive_path"
cleanup_old_backups
log "Backup process complete"
Troubleshooting Tips
- "tar: Removing leading '/' from member names" — the path being archived starts with an absolute
/; this warning shouldn't appear at all when-Cand a relative path are used correctly — if it does, re-check the-C/basenamelines increate_backup. - The backup directory is getting archived into itself — if
BACKUP_DIRsits insideSOURCE_DIR(for example,data/backups), every subsequent backup also includes previous archives, and archive size grows quickly. Always keep the backup directory outside the source directory. cleanup_old_backupsisn't deleting anything — test theRETENTION_DAYSvalue andfind's-mtimecondition separately, without-delete(see step 4 above).- The script doesn't work under
cron, but works manually — usually the cause iscron's shorter$PATH, or a relative path being used; write every path in the script as absolute.cronconfiguration is covered in cron Environment and Logs.
Rollback and Cleanup
Once the lab is done, the test directories can be removed entirely:
If the script was tested against a real data directory and the wrong files were deleted, restore from the most recent, verified (verify_backup-confirmed) archive in the backups directory:
Final Assessment Criterion
The project is considered successful if all of the following hold:
- run without arguments, the script prints a usage message with exit code
2; - run against a nonexistent source directory, it fails clearly with exit code
1; - on success, an archive is created, confirmed with
tar -tzf, and logged tobackup.log; - archives older than
RETENTION_DAYSare deleted automatically, but only based on the deliberately confirmed-mtimecondition; - running the script repeatedly behaves the same way each time, with no errors (idempotency).
Summary
This project combined every piece of the module — argument validation, functions, exit codes, set -euo pipefail, and testing a dangerous action with a "dry run" first via find — into one working backup script. The next project, Healthcheck Script, applies a similar structure — functions, exit codes, logging — to checking system resources (disk, services, network) instead of archiving files.