I’ve lost data exactly twice in my life, and both times it happened because I “meant to set up backups eventually.” The third time didn’t happen, because I finally sat down and built a proper Bash backup tool that runs on its own schedule without me having to remember anything. In this article, I’ll walk through exactly how I built it — from a bare-bones version to something with rotation, logging, and remote copies built in.
This tool pairs directly with the Bash File Restore Tool covered elsewhere in this series, so I’d recommend reading both.
What a Good Backup Script Actually Needs
Before writing code, I mapped out the requirements:
- Compress the target folder into a timestamped archive
- Keep a rolling window of backups instead of accumulating forever
- Optionally copy the backup somewhere off-machine
- Log every run so I can audit what happened
- Fail loudly if something goes wrong, rather than silently
Prerequisites
sudo apt install tar gzip rsync
Step 1: The Core Backup Script
#!/usr/bin/env bash
set -euo pipefail
SOURCE="${1:-}"
BACKUP_DIR="${2:-$HOME/backups}"
if [[ -z "$SOURCE" || ! -d "$SOURCE" ]]; then
echo "Usage: $0 <source-directory> [backup-directory]"
exit 1
fi
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
ARCHIVE_NAME="backup-${TIMESTAMP}.tar.gz"
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
tar -czf "$ARCHIVE_PATH" -C "$(dirname "$SOURCE")" "$(basename "$SOURCE")"
echo "Backup created: $ARCHIVE_PATH"
How This Works
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")generates a sortable, collision-resistant timestamp, which is exactly what our Restore Tool script from earlier in this series expects when listing and sorting backups.tar -czf "$ARCHIVE_PATH" -C "$(dirname "$SOURCE")" "$(basename "$SOURCE")"is worth slowing down on:-Cchanges directory before adding files, and we pass the parent directory of our source, then reference just the folder’s basename. This ensures the archive contains a clean relative path (e.g.myproject/) rather than an absolute path baked into every entry, which makes the archive portable and safe to extract anywhere.
Step 2: Verifying the Backup Succeeded
A backup that silently fails is worse than no backup at all, because it gives you false confidence. Let’s verify:
if tar -tzf "$ARCHIVE_PATH" &>/dev/null; then
SIZE=$(du -h "$ARCHIVE_PATH" | cut -f1)
echo "Verified OK. Size: $SIZE"
else
echo "ERROR: Backup archive appears corrupted!" >&2
exit 1
fi
tar -tzf (list contents) doubles as a lightweight integrity check here — if the archive is truncated or corrupted, this command will fail with a non-zero exit code, which we catch with the if statement.
Step 3: Adding Rotation (Keeping Only the Last N Backups)
Without rotation, your backup folder grows forever. Let’s keep only the most recent 7:
KEEP=7
BACKUP_COUNT=$(find "$BACKUP_DIR" -maxdepth 1 -iname "backup-*.tar.gz" | wc -l)
if (( BACKUP_COUNT > KEEP )); then
find "$BACKUP_DIR" -maxdepth 1 -iname "backup-*.tar.gz" | sort | head -n "-${KEEP}" | while IFS= read -r old_backup; do
echo "Removing old backup: $(basename "$old_backup")"
rm -f "$old_backup"
done
fi
Breaking This Down
find ... | sort | head -n "-${KEEP}"— sorting the filenames alphabetically also sorts them chronologically, since our timestamp format (YYYYMMDD-HHMMSS) is lexically sortable.head -n "-7"is a neat trick: a negative number tellsheadto print all lines except the last 7, which gives us exactly the older backups we want to delete while keeping the newest 7 intact.(( BACKUP_COUNT > KEEP ))uses Bash’s arithmetic evaluation context, which is cleaner than[[ "$BACKUP_COUNT" -gt "$KEEP" ]]for pure numeric comparisons.
Step 4: Logging Every Run
LOG_DIR="$HOME/.local/share/backup-tool/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/backup.log"
{
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup of $SOURCE"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Archive: $ARCHIVE_PATH"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Size: $SIZE"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Status: SUCCESS"
echo "---"
} >> "$LOG_FILE"
The { ... } >> "$LOG_FILE" syntax groups multiple commands and redirects all of their combined output to a single file with one >> (append) redirect, which is more efficient and readable than repeating the redirect on every line.
Step 5: Optional Off-Site Copy via rsync
REMOTE="${3:-}"
if [[ -n "$REMOTE" ]]; then
echo "Copying backup to remote: $REMOTE"
rsync -av "$ARCHIVE_PATH" "$REMOTE"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Copied to $REMOTE" >> "$LOG_FILE"
fi
This makes the third argument optional — if supplied (e.g. user@host:/backups/), the freshly created archive gets copied off-machine immediately, following the 3-2-1 backup principle (three copies, two different media, one off-site).
Full Combined Script
#!/usr/bin/env bash
set -euo pipefail
SOURCE="${1:-}"
BACKUP_DIR="${2:-$HOME/backups}"
REMOTE="${3:-}"
KEEP=7
if [[ -z "$SOURCE" || ! -d "$SOURCE" ]]; then
echo "Usage: $0 <source-dir> [backup-dir] [remote-destination]"
exit 1
fi
mkdir -p "$BACKUP_DIR"
LOG_DIR="$HOME/.local/share/backup-tool/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/backup.log"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
ARCHIVE_PATH="${BACKUP_DIR}/backup-${TIMESTAMP}.tar.gz"
tar -czf "$ARCHIVE_PATH" -C "$(dirname "$SOURCE")" "$(basename "$SOURCE")"
if tar -tzf "$ARCHIVE_PATH" &>/dev/null; then
SIZE=$(du -h "$ARCHIVE_PATH" | cut -f1)
echo "Backup verified OK. Size: $SIZE"
else
echo "ERROR: Backup archive appears corrupted!" >&2
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Status: FAILED - corrupted archive" >> "$LOG_FILE"
exit 1
fi
BACKUP_COUNT=$(find "$BACKUP_DIR" -maxdepth 1 -iname "backup-*.tar.gz" | wc -l)
if (( BACKUP_COUNT > KEEP )); then
find "$BACKUP_DIR" -maxdepth 1 -iname "backup-*.tar.gz" | sort | head -n "-${KEEP}" | while IFS= read -r old; do
rm -f "$old"
done
fi
if [[ -n "$REMOTE" ]]; then
rsync -av "$ARCHIVE_PATH" "$REMOTE"
fi
{
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup of $SOURCE -> $ARCHIVE_PATH ($SIZE) - SUCCESS"
} >> "$LOG_FILE"
echo "Backup complete: $ARCHIVE_PATH"
Real-World Use Cases
- Home server folders — photos, documents, and media libraries backed up nightly.
- Website/database directories — pairing this with a
mysqldumpstep before compression for full application backups. - Developer configuration (“dotfiles”) — backing up
.config,.ssh(carefully, see security notes below), and shell profile files. - Small business file shares, where a lightweight script is often more practical than a full backup suite.
Automating with Cron
0 2 * * * /usr/local/bin/backup.sh /home/user/documents /home/user/backups user@offsite:/backups/documents >> /var/log/backup-cron.log 2>&1
Runs every night at 2 AM, backs up the documents folder, rotates old backups, and copies the fresh archive off-site.
Security Considerations
- Never back up unencrypted private keys or credentials into a shared or off-site location without encrypting first. Pipe sensitive backups through GPG:
tar -czf - "$SOURCE" | gpg -c -o "$ARCHIVE_PATH.gpg". - Restrict permissions on the backup directory:
chmod 700 "$BACKUP_DIR"ensures only your user can read backup contents. - Use SSH key authentication for remote copies, never embedded passwords, to keep the script safe to store in version control or share with teammates.
- Verify backup integrity regularly, not just at creation time — bit rot and storage failures can corrupt archives after the fact; periodic
tar -tzfchecks or checksums catch this.
Optimization Tips
- For very large directories, consider incremental backups using
tar‘s--listed-incrementalflag, which only archives files that changed since the last run, dramatically reducing backup time and size. - Use
niceandioniceto run backups without starving other processes on a busy system:nice -n 19 ionice -c3 ./backup.sh .... - Compress with
pigz(parallel gzip) for large datasets on multi-core machines to cut backup duration significantly.
Troubleshooting
- Backup script runs but the archive is empty — check that
$SOURCEactually points to the intended directory and that you have read permissions on all its contents. - Cron job doesn’t run — cron uses a minimal environment; use absolute paths everywhere in the script and test with
env -i bash backup.sh ...to simulate cron’s stripped-down environment. - “No space left on device” — your rotation
KEEPvalue might be too high for your available disk space; lower it or move backups to larger storage. - rsync remote copy fails silently in cron — make sure SSH key auth works non-interactively; test by running the exact same rsync command manually from the same user account cron uses.
Common Mistakes to Avoid
- Storing backups on the same physical disk as the original data — a single disk failure destroys both.
- Never testing whether the backups can actually be restored (see the Restore Tool article for testing automation).
- Forgetting to rotate backups, eventually filling up the disk.
- Running backup scripts as root unnecessarily, which increases the blast radius if the script has a bug.
Frequently Asked Questions
How often should I run backups? Depends on how much data you can afford to lose. Nightly is a reasonable default for most personal and small business use; critical databases may warrant hourly incremental backups.
Should backups be encrypted? Yes, especially for anything containing personal, financial, or credential data, and especially before copying to remote or cloud storage you don’t fully control.
What’s the difference between this and just using a cloud backup service? This script gives you full control and transparency over exactly what’s happening, works entirely offline if needed, and costs nothing beyond your own storage — but it also means you’re responsible for monitoring it, unlike a managed service.
Summary
We built a Bash backup tool that compresses a target directory into a timestamped, verified archive, automatically rotates old backups to control disk usage, logs every run for auditability, and optionally pushes a copy off-site via rsync. The recurring theme across this entire series holds true here too: verification and logging aren’t optional extras — they’re what separates a backup script you can actually trust from one that just gives you false confidence.