Skip to content

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:

  1. point BACKUP_DIR at a test directory like ~/lab/backup-project/backups above, not a real backup directory;
  2. first test the deletion line as a listing only, without -delete (shown below);
  3. 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:

chmod +x backup.sh
./backup.sh

Expected result:

Error: not enough arguments.
Usage: ./backup.sh <source_directory> <backup_directory> [days_to_keep]
echo "Exit code: $?"
Exit code: 2

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:

create_backup

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:

find "$BACKUP_DIR" -name "*.tar.gz" -mtime "+${RETENTION_DAYS}"

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:

cleanup_old_backups

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 -C and a relative path are used correctly — if it does, re-check the -C/basename lines in create_backup.
  • The backup directory is getting archived into itself — if BACKUP_DIR sits inside SOURCE_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_backups isn't deleting anything — test the RETENTION_DAYS value and find's -mtime condition separately, without -delete (see step 4 above).
  • The script doesn't work under cron, but works manually — usually the cause is cron's shorter $PATH, or a relative path being used; write every path in the script as absolute. cron configuration is covered in cron Environment and Logs.

Rollback and Cleanup

Once the lab is done, the test directories can be removed entirely:

rm -rf ~/lab/backup-project

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:

tar -xzf /backup/data-20260726-141541.tar.gz -C /tmp/restore-test

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 to backup.log;
  • archives older than RETENTION_DAYS are deleted automatically, but only based on the deliberately confirmed -mtime condition;
  • 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.

References