The backup script this page used to publish died silently on the night it mattered most. When tar hit a file that changed while it was being read, set -e killed the script before its own error handling ran, so the log just stopped. You got no “Backup failed” line, no retention cleanup and a failed systemd unit with nothing to explain it. We reproduced that on a fresh server, fixed it, and rebuilt the bash backup script around what the lab showed.
Our rebuilt version archives your directories into one zstd tarball, keeps separate retention per profile, queues overlapping runs on a lock and runs from systemd timers. You also get a restore drill and a 266 MB compression benchmark. Every command was re-run on Ubuntu 26.04 in October 2026 with bash 5.3, GNU tar 1.35 and zstd 1.5.7.
Check the prerequisites
Nothing here comes from a third-party repository, because the script only needs tools that ship with a normal Linux install. We tested on Ubuntu 26.04 only; other systemd distros carry the same tools, but check package names there yourself. What you do need is somewhere to put the archives that is not the disk you are protecting, because a backup on the same disk dies with it. Before you start, make sure you have:
- Bash, GNU tar, flock and systemd, all part of a standard Ubuntu install
- The zstd binary for the default compression, or gzip/xz if you switch
- Root or sudo access, since the script reads
/etc/shadowand other root-only files - A destination mounted at
/backup, such as a second disk, an NFS share or a USB drive
For a USB target, any of the drives in our external hard drives for Linux backups roundup works; format it ext4 and mount it at /backup through /etc/fstab. Then run sudo chmod 700 /backup, because a fresh filesystem root is usually mode 755 and the script only sets 700 on a directory it creates itself. Our Ubuntu 26.04 VM already had zstd and gzip, but bzip2 was not installed, and that gap broke the old script in a way you will see further down. Confirm the compressor you plan to use exists before you go on.
command -v zstd gzip xz bzip2
Each tool that is installed prints its path, and a missing one prints nothing. If zstd is missing, install it with sudo apt install zstd, because the script refuses to start without the compressor you configured.
Create the bash backup script
Keep the script in /usr/local/bin so the systemd unit you add later can call it by absolute path, and so package upgrades never touch it. Open a new file as root.
sudo vim /usr/local/bin/system-backup.sh
Paste the full script below. It is the exact file we ran for every result on this page, so you can compare your output with ours line by line. Leave the defaults alone for now, because the first run is easier to debug when it matches ours.
#!/bin/bash
# system-backup.sh: compressed, timestamped tar backups with retention.
# Usage: system-backup.sh [config_file]
set -euo pipefail
umask 077
# Defaults. A config file passed as $1 overrides any of these.
BACKUP_NAME="daily" # archive prefix; give each profile its own name
BACKUP_DIR="/backup"
DIRECTORIES_TO_BACKUP=("/etc" "/home" "/root" "/opt")
EXCLUDE_PATTERNS=("*.tmp" "*.swp" "*.swx" ".cache" "__pycache__" "node_modules" "/home/*/Downloads" "/home/*/.local/share/Trash")
RETENTION_DAYS=30
LOG_FILE="/var/log/system-backup.log"
DATE_FORMAT="%Y-%m-%d_%H-%M-%S"
COMPRESSION_TYPE="zstd" # zstd, gzip, xz or bzip2
REQUIRED_SPACE_GB=1
LOCK_FILE="/run/system-backup.lock"
LOCK_WAIT_SECONDS=1800 # how long a second run waits for the first to finish
REQUIRE_MOUNT=0 # 1 = refuse to run unless BACKUP_DIR is a mount point
log() {
local level="$1"
shift
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "$LOG_FILE"
}
die() {
log "ERROR" "$*"
exit 1
}
load_config() {
local cfg="$1"
[ -f "$cfg" ] || die "Config file $cfg not found"
log "INFO" "Loading configuration from $cfg"
# shellcheck source=/dev/null
source "$cfg"
}
check_root() {
[ "$(id -u)" -eq 0 ] || { echo "This script must be run as root" >&2; exit 1; }
}
acquire_lock() {
exec 9>"$LOCK_FILE"
flock -w "$LOCK_WAIT_SECONDS" 9 || die "Another backup still running after ${LOCK_WAIT_SECONDS}s (lock: $LOCK_FILE)"
}
check_disk_space() {
local required_kb=$((REQUIRED_SPACE_GB * 1048576))
local available_kb
available_kb=$(df -Pk "$BACKUP_DIR" | awk 'NR==2 {print $4}')
local available_gb
available_gb=$(awk "BEGIN {printf \"%.1f\", $available_kb / 1048576}")
if [ "$available_kb" -lt "$required_kb" ]; then
die "Not enough disk space. Required: ${REQUIRED_SPACE_GB}GB, Available: ${available_gb}GB"
fi
log "INFO" "Disk space available: ${available_gb}GB (need ${REQUIRED_SPACE_GB}GB)"
}
create_backup() {
local backup_file
backup_file="${BACKUP_DIR}/backup_${BACKUP_NAME}_$(hostname -s)_$(date +"$DATE_FORMAT").tar"
local compression_opt compressor
case "$COMPRESSION_TYPE" in
zstd) backup_file+=".zst"; compression_opt="--zstd"; compressor="zstd" ;;
gzip) backup_file+=".gz"; compression_opt="-z"; compressor="gzip" ;;
xz) backup_file+=".xz"; compression_opt="-J"; compressor="xz" ;;
bzip2) backup_file+=".bz2"; compression_opt="-j"; compressor="bzip2" ;;
*) die "Unknown COMPRESSION_TYPE: $COMPRESSION_TYPE" ;;
esac
command -v "$compressor" >/dev/null || die "$compressor is not installed"
local exclude_args=()
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
exclude_args+=("--exclude=$pattern")
done
local valid_sources=()
for src in "${DIRECTORIES_TO_BACKUP[@]}"; do
if [ -d "$src" ]; then
valid_sources+=("$src")
else
log "WARNING" "Skipping $src (does not exist)"
fi
done
[ ${#valid_sources[@]} -gt 0 ] || die "No valid source directories to back up"
log "INFO" "Backing up: ${valid_sources[*]}"
log "INFO" "Destination: $backup_file"
local err_log rc=0
err_log=$(mktemp)
tar "$compression_opt" -cf "$backup_file" "${exclude_args[@]}" \
"${valid_sources[@]}" 2>"$err_log" || rc=$?
# tar exit 1 means some files changed while being read; the archive is still usable.
if [ "$rc" -le 1 ]; then
log "INFO" "Backup complete: $backup_file ($(du -h "$backup_file" | cut -f1))"
if [ "$rc" -eq 1 ]; then
log "WARNING" "Some files changed during archiving (tar exit 1, non-fatal)"
log "WARNING" "$(grep -v 'Removing leading' "$err_log" | head -5)"
fi
rm -f "$err_log"
else
log "ERROR" "Backup failed (tar exit code $rc)"
log "ERROR" "$(head -5 "$err_log")"
rm -f "$err_log" "$backup_file"
exit 1
fi
}
clean_old_backups() {
if [ "$RETENTION_DAYS" -le 0 ]; then
log "INFO" "Retention disabled, skipping cleanup"
return
fi
local count
count=$(find "$BACKUP_DIR" -maxdepth 1 -name "backup_${BACKUP_NAME}_*.tar.*" -mtime +"$RETENTION_DAYS" | wc -l)
if [ "$count" -gt 0 ]; then
find "$BACKUP_DIR" -maxdepth 1 -name "backup_${BACKUP_NAME}_*.tar.*" -mtime +"$RETENTION_DAYS" -delete
log "INFO" "Removed $count $BACKUP_NAME backup(s) older than $RETENTION_DAYS days"
else
log "INFO" "No $BACKUP_NAME backups older than $RETENTION_DAYS days to remove"
fi
}
check_root
if [ $# -gt 0 ]; then
load_config "$1"
fi
acquire_lock
if [ "$REQUIRE_MOUNT" -eq 1 ] && ! mountpoint -q "$BACKUP_DIR"; then
die "$BACKUP_DIR is not a mount point (REQUIRE_MOUNT=1)"
fi
mkdir -p "$BACKUP_DIR"
log "INFO" "========== BACKUP STARTED =========="
START=$(date +%s)
check_disk_space
create_backup
clean_old_backups
log "INFO" "Total runtime: $(( $(date +%s) - START ))s"
log "INFO" "========== BACKUP FINISHED =========="
Each change from the earlier version came from something that broke in the lab, and ShellCheck 0.11 flagged none of them, because every line was valid Bash. The fixes you get in this version:
- tar ends in
|| rc=$?, so exit code 1 no longer tripsset -e umask 077keeps archives and/backuproot-onlycommand -vchecks the compressor before tar starts- tar errors go to a
mktempfile, not a fixed/tmppath - a failed run deletes its partial archive
BACKUP_NAMEkeeps each profile’s retention to its own filesflock -wqueues a second run instead of dropping it
The umask matters more than it looks. A copy of /etc carries /etc/shadow, and the old script left archives as -rw-r--r--, so every local user could read your password hashes from /backup. If you ran the old version, check that directory after you switch.
Make it executable. The systemd unit calls the script by its path rather than through bash, so the execute bit isn’t optional.
sudo chmod +x /usr/local/bin/system-backup.sh
If you want to learn the Bash behind lines like exec 9>"$LOCK_FILE" or "${exclude_args[@]}" instead of copying them, the Bash scripting books we recommend cover file descriptors, arrays and strict mode properly. The set builtin section of the Bash manual explains exactly when -e does and doesn’t exit, which is the rule the old script tripped over.
Tune the defaults
Every setting sits at the top of the script, and any of them can be overridden from a config file without touching the script itself. That split, plus a different BACKUP_NAME per profile, is what lets one script run a nightly zstd backup and a monthly xz archive with different retention in the same directory:
| Variable | Default | What it controls |
|---|---|---|
BACKUP_NAME | daily | Prefix in the archive name; retention only touches archives with this prefix |
BACKUP_DIR | /backup | Where archives are written; created with mode 700 if missing |
DIRECTORIES_TO_BACKUP | /etc /home /root /opt | Sources; a missing one is skipped with a warning |
EXCLUDE_PATTERNS | temp, swap, cache, __pycache__, node_modules, Downloads, Trash | Patterns tar skips |
RETENTION_DAYS | 30 | Archives of this profile older than this are deleted; 0 disables cleanup |
COMPRESSION_TYPE | zstd | zstd, gzip, xz or bzip2 |
REQUIRED_SPACE_GB | 1 | Minimum free space on the destination before tar starts |
LOG_FILE | /var/log/system-backup.log | Appended on every run |
LOCK_FILE | /run/system-backup.lock | flock target shared by all profiles |
REQUIRE_MOUNT | 0 | Set to 1 to stop unless BACKUP_DIR is a mount point |
LOCK_WAIT_SECONDS | 1800 | How long a run waits for another to finish before giving up |
Set REQUIRED_SPACE_GB to at least the size of your last archive plus some growth, because the default of 1 GB only stops a run on a nearly full disk and a half-written archive is worse than none. We checked the exclude list by putting files in ~/Downloads and ~/.cache and counting members in the next archive: zero entries for either, and zero __pycache__ paths although /opt held 449 of those directories, while ~/notes.txt was there as expected. So the leading-slash patterns like /home/*/Downloads do match, even though tar strips the slash from member names.
Run the first backup
A manual run as root shows each stage, and it is the quickest way to catch a typo in a path before the timer takes over. Run it once by hand, because a failure here prints straight to your terminal instead of a journal you have to go looking in.
sudo /usr/local/bin/system-backup.sh
On our VM the four default directories, including about 300 MB under /opt and a git repository in the home directory, compressed to an 83 MB zstd archive in about a second. Your numbers will differ, but every run should end with the FINISHED line, because the script now logs an ERROR and exits before reaching it on any real failure:
[2026-10-17 20:23:12] [INFO] ========== BACKUP STARTED ==========
[2026-10-17 20:23:12] [INFO] Disk space available: 14.8GB (need 1GB)
[2026-10-17 20:23:12] [INFO] Backing up: /etc /home /root /opt
[2026-10-17 20:23:12] [INFO] Destination: /backup/backup_daily_bashlab01_2026-10-17_20-23-12.tar.zst
[2026-10-17 20:23:13] [INFO] Backup complete: /backup/backup_daily_bashlab01_2026-10-17_20-23-12.tar.zst (83M)
[2026-10-17 20:23:13] [INFO] No daily backups older than 30 days to remove
[2026-10-17 20:23:13] [INFO] Total runtime: 1s
[2026-10-17 20:23:13] [INFO] ========== BACKUP FINISHED ==========
Next, check the permissions on what it wrote, since that is where the old script leaked.
sudo ls -l /backup/
Root owns the archive and it’s -rw-------, which is what you want for a file that holds password hashes. The daily in the name is the profile prefix:
total 84256
-rw------- 1 root root 86275000 Oct 17 20:23 backup_daily_bashlab01_2026-10-17_20-23-12.tar.zst
If you see -rw-r--r-- here, you’re running an older copy of the script without umask 077, which means any local user can read the archive. Tighten the existing archives after updating with sudo find /backup -maxdepth 1 -type f -name 'backup_*' -exec chmod 600 {} +. A plain sudo chmod 600 /backup/* fails with “cannot access” once /backup is mode 700, because your own shell expands the glob. The same run looks like this in the terminal.

Every run appends the same lines to /var/log/system-backup.log, so the file doubles as a history of sizes and runtimes. Read it after the first week, because a sudden jump in archive size is usually a new cache or build directory that you should add to EXCLUDE_PATTERNS.
Add a monthly long-retention profile
A config file passed as the first argument overrides the defaults for that run only. We use one for a monthly xz archive kept for 400 days, next to the 30-day daily zstd archives. Its BACKUP_NAME is what keeps the two apart: the daily run only prunes backup_daily_* files and the monthly run only prunes backup_monthly_*. Create the profile file.
sudo vim /etc/backup-monthly.conf
This profile backs up the same four directories with xz. Anything it doesn’t set, such as the destination, excludes and lock, keeps the script’s default:
BACKUP_NAME="monthly"
DIRECTORIES_TO_BACKUP=("/etc" "/home" "/root" "/opt")
COMPRESSION_TYPE="xz"
RETENTION_DAYS=400
Because the script sources this file as root, anyone who can write to it can run commands as root. Lock it down before the first run and keep it owned by root.
sudo chmod 600 /etc/backup-monthly.conf
Then pass the profile as the first argument. A mistyped path is a hard error rather than a silent fall back to the defaults, because a profile that quietly backs up the wrong thing is worse than one that refuses to run.
sudo /usr/local/bin/system-backup.sh /etc/backup-monthly.conf
Look for the “Loading configuration” line first, since it proves the file was read, and note the monthly prefix and .tar.xz suffix:
[2026-10-17 20:23:23] [INFO] Loading configuration from /etc/backup-monthly.conf
[2026-10-17 20:23:23] [INFO] ========== BACKUP STARTED ==========
[2026-10-17 20:23:23] [INFO] Disk space available: 14.7GB (need 1GB)
[2026-10-17 20:23:23] [INFO] Backing up: /etc /home /root /opt
[2026-10-17 20:23:23] [INFO] Destination: /backup/backup_monthly_bashlab01_2026-10-17_20-23-23.tar.xz
[2026-10-17 20:24:06] [INFO] Backup complete: /backup/backup_monthly_bashlab01_2026-10-17_20-23-23.tar.xz (73M)
[2026-10-17 20:24:06] [INFO] No monthly backups older than 400 days to remove
[2026-10-17 20:24:06] [INFO] Total runtime: 43s
[2026-10-17 20:24:06] [INFO] ========== BACKUP FINISHED ==========
xz took 43 seconds for a 73 MB archive of the same data zstd handled in about a second at 83 MB, which is why it belongs on the monthly job and not the nightly one. We then planted fake archives dated 30, 31 and 45 days back for daily and 45 and 200 days back for monthly, and ran the daily profile. It reported “Removed 2 daily backup(s) older than 30 days” and left every monthly archive alone; the following monthly run removed nothing, as its 400-day window requires.
Restore files from an archive
An archive you’ve never restored from is a hope, not a backup, so run this drill once now and again after any change to the script. Pick the newest daily archive first. The glob has to expand inside a root shell, because /backup is mode 700 and your own shell can’t list it. Plain sudo ls /backup/backup_* fails with “No such file or directory” for that reason.
ARCHIVE=$(sudo sh -c 'ls -t /backup/backup_daily_*.tar.zst' | head -1)
echo "$ARCHIVE"
It prints the full path of the most recent archive. If it prints nothing, no daily archive exists yet, so change the prefix and suffix in the pattern to match the profile you want to restore from:
/backup/backup_daily_bashlab01_2026-10-17_20-25-51.tar.zst
Extract everything into a scratch directory rather than over the live system. GNU tar reads the compression format from the file itself, so you never need a compression flag when extracting.
mkdir -p /tmp/restore-test
sudo tar -xf "$ARCHIVE" -C /tmp/restore-test
sudo find /tmp/restore-test -maxdepth 1
All four source directories come back at the top level, without the leading slash, because tar strips it when it writes the archive. That stripping is what keeps a plain tar -xf from overwriting your live /etc:
/tmp/restore-test
/tmp/restore-test/opt
/tmp/restore-test/root
/tmp/restore-test/home
/tmp/restore-test/etc
We compared the restored tree with the live one using sudo diff -r /etc /tmp/restore-test/etc. The only complaints were three symlinks, /etc/mtab, /etc/os-release and /etc/resolv.conf. On Ubuntu 26.04 they are relative links such as ../proc/self/mounts, so inside the scratch directory they point at paths that don’t exist there. tar stores symlinks as links, so that is expected and not data loss.
To put a single file back in place, extract it relative to /. We overwrote /etc/hostname with junk and recovered it this way:
sudo tar -xf "$ARCHIVE" -C / etc/hostname
cat /etc/hostname
Seeing bashlab01 again proves the archive is readable and the member path is right.
bashlab01
Member names have no leading slash, so etc/hostname works and /etc/hostname fails with “Not found in archive”. Extracting with -C / overwrites the live file without asking, so restore into the scratch directory first whenever the file matters. Clean up with sudo rm -rf /tmp/restore-test when you’re done.
Schedule it with systemd timers
A systemd timer beats cron here, because it fixes three things cron can’t. The run’s output lands in the journal, and Persistent=true catches up a run missed while the server was off. The unit can also refuse to run when the backup disk isn’t mounted. If timers are new to you, our systemd timers guide covers the calendar syntax in depth. Start with the daily service, which runs the script with its defaults.
sudo vim /etc/systemd/system/system-backup.service
RequiresMountsFor=/backup makes systemd mount /backup from fstab before a timer run and fail the unit if that mount fails. It does nothing for a manual run or when /backup has no fstab entry, and we did not test a failed mount through systemd. The script’s own guard covers every case: set REQUIRE_MOUNT=1 in the script or a profile and it refuses to write unless BACKUP_DIR is a mount point. We tested it with /backup unmounted:
[2026-10-17 20:51:16] [INFO] Loading configuration from /tmp/reqmount.conf
[2026-10-17 20:51:16] [ERROR] /backup is not a mount point (REQUIRE_MOUNT=1)
With a tmpfs mounted at /backup the same profile ran normally, so turn the guard on whenever /backup is a separate disk; it stays off by default so a plain directory works. Nice=19 keeps the CPU cost down. IOSchedulingClass=idle only takes effect with the BFQ or mq-deadline I/O schedulers; our virtio disk used none (check /sys/block/vda/queue/scheduler), where the setting is accepted but changes nothing.
[Unit]
Description=Daily directory backup
After=network-online.target
Wants=network-online.target
RequiresMountsFor=/backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/system-backup.sh
Nice=19
IOSchedulingClass=idle
Now create the timer that triggers it. Its name has to match the service, because systemd pairs system-backup.timer with system-backup.service when the timer sets no Unit= line.
sudo vim /etc/systemd/system/system-backup.timer
It fires daily at 02:00 plus a random delay of up to 15 minutes, which keeps a fleet of servers from hitting the same NFS share in the same second.
[Unit]
Description=Run the daily backup at 2:00 AM
[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=900
Persistent=true
[Install]
WantedBy=timers.target
The monthly profile gets its own pair of units. The service is the same apart from its description and the config file argument:
sudo vim /etc/systemd/system/system-backup-monthly.service
Paste the monthly service, which points at /etc/backup-monthly.conf:
[Unit]
Description=Monthly long-retention backup
After=network-online.target
Wants=network-online.target
RequiresMountsFor=/backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/system-backup.sh /etc/backup-monthly.conf
Nice=19
IOSchedulingClass=idle
Then the monthly timer. It runs on the 1st at 03:30, after the daily window, so the two rarely meet; when they do, the second one waits on the lock.
sudo vim /etc/systemd/system/system-backup-monthly.timer
Paste the timer definition:
[Unit]
Description=Run the monthly backup on the 1st at 3:30 AM
[Timer]
OnCalendar=*-*-01 03:30:00
RandomizedDelaySec=900
Persistent=true
[Install]
WantedBy=timers.target
Reload systemd, enable both timers and check when they fire next. Repeat the reload after any edit to a unit, otherwise systemd keeps using the copy it loaded before.
sudo systemctl daemon-reload
sudo systemctl enable --now system-backup.timer system-backup-monthly.timer
systemctl list-timers 'system-backup*'
Look at the NEXT column, which shows the randomized delay at work (02:05 instead of 02:00, and 03:38 on the 1st):
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sun 2026-10-18 02:05:11 UTC 5h 42min - - system-backup.timer system-backup.service
Sun 2026-11-01 03:38:43 UTC 2 weeks 0 days - - system-backup-monthly.timer system-backup-monthly.service
2 timers listed.
Don’t wait until 2 AM to find out whether the units work. Start the monthly service by hand and read its journal, because a path or permission problem shows up there in seconds.
sudo systemctl start system-backup-monthly.service
sudo journalctl -u system-backup-monthly.service --no-pager
A healthy run ends with “Deactivated successfully” and a resource summary. If you see “Failed with result ‘exit-code'” instead, the script’s own ERROR line a few lines above it names the cause.
Oct 17 20:25:52 bashlab01 systemd[1]: Starting system-backup-monthly.service - Monthly long-retention backup...
Oct 17 20:25:52 bashlab01 system-backup.sh[68488]: [2026-10-17 20:25:52] [INFO] Loading configuration from /etc/backup-monthly.conf
Oct 17 20:25:52 bashlab01 system-backup.sh[68493]: [2026-10-17 20:25:52] [INFO] ========== BACKUP STARTED ==========
Oct 17 20:25:52 bashlab01 system-backup.sh[68501]: [2026-10-17 20:25:52] [INFO] Disk space available: 14.0GB (need 1GB)
Oct 17 20:25:52 bashlab01 system-backup.sh[68506]: [2026-10-17 20:25:52] [INFO] Backing up: /etc /home /root /opt
Oct 17 20:25:52 bashlab01 system-backup.sh[68509]: [2026-10-17 20:25:52] [INFO] Destination: /backup/backup_monthly_bashlab01_2026-10-17_20-25-52.tar.xz
Oct 17 20:26:37 bashlab01 system-backup.sh[68546]: [2026-10-17 20:26:37] [INFO] Backup complete: /backup/backup_monthly_bashlab01_2026-10-17_20-25-52.tar.xz (73M)
Oct 17 20:26:37 bashlab01 system-backup.sh[68553]: [2026-10-17 20:26:37] [INFO] No monthly backups older than 400 days to remove
Oct 17 20:26:37 bashlab01 system-backup.sh[68557]: [2026-10-17 20:26:37] [INFO] Total runtime: 45s
Oct 17 20:26:37 bashlab01 system-backup.sh[68560]: [2026-10-17 20:26:37] [INFO] ========== BACKUP FINISHED ==========
Oct 17 20:26:37 bashlab01 systemd[1]: system-backup-monthly.service: Deactivated successfully.
Oct 17 20:26:37 bashlab01 systemd[1]: Finished system-backup-monthly.service - Monthly long-retention backup.
That last line is worth reading. xz used 1 minute 25 seconds of CPU in 44.9 seconds of wall time, so it ran on both cores, and the unit peaked at 363.3 MB of memory. The daily zstd service on the same data took 1.1 seconds and peaked at 140.2 MB. On a small VPS that gap is the reason xz stays on the monthly job. If a run fails, systemctl --failed lists the unit, and the journal carries the script’s own ERROR line instead of ending mid-run.
Fix common backup script errors
These are the failures we hit while testing, with the exact messages. Each one used to end the old script without a useful log line, which is the reason they are worth knowing even though the current version handles them.
tar: file changed as we read it
This happens when a log or database file is written to while tar reads it. We reproduced it by appending to a log in /srv/live during a run. tar exits with code 1, which GNU tar documents as “some files differ” rather than a failure. With the old script, set -e ended the run right there: the log stopped after the “Destination” line, retention never ran and the exit code was 1. The current script logs it as a warning and carries on.
[2026-10-17 20:26:56] [INFO] Backup complete: /backup/backup_live_bashlab01_2026-10-17_20-26-49.tar.gz (251M)
[2026-10-17 20:26:56] [WARNING] Some files changed during archiving (tar exit 1, non-fatal)
[2026-10-17 20:26:56] [WARNING] tar: /srv/live/app.log: file changed as we read it
[2026-10-17 20:26:57] [INFO] No live backups older than 30 days to remove
Treat the changed file in that archive as a point-in-time copy that may be torn, so don’t rely on tar for a live database. Dump it first (pg_dump, mariadb-dump) into a directory the script backs up.
/bin/sh: 1: bzip2: not found
bzip2 isn’t installed on a fresh Ubuntu 26.04 server. With the old script, choosing it left a 0-byte .tar.bz2 in /backup and exited with code 2 and no ERROR line, because tar’s own messages went to a temp file nobody read. The current script checks first and stops cleanly.
[2026-10-17 20:27:03] [ERROR] bzip2 is not installed
Install it with sudo apt install bzip2, or better, switch to zstd. The benchmark below shows bzip2 losing to zstd on both speed and size, so there’s little reason to keep it unless another tool in your chain only reads .bz2.
zstd: error 70 : Write error : cannot write block : No space left on device
We filled a 1 MB tmpfs on purpose, with REQUIRED_SPACE_GB=0 so the space check could not stop the run. tar exited with code 2 and the script logged the real cause from tar and zstd. It also deleted the half-written archive, so a truncated file never sits in /backup pretending to be a good one:
[2026-10-17 20:27:03] [ERROR] Backup failed (tar exit code 2)
[2026-10-17 20:27:03] [ERROR] tar: Removing leading `/' from member names
zstd: error 70 : Write error : cannot write block : No space left on device
tar: /mnt/tiny/backup_daily_bashlab01_2026-10-17_20-27-03.tar.zst: Wrote only 8192 of 10240 bytes
tar: Child returned status 70
tar: Error is not recoverable: exiting now
Free space on the destination or point BACKUP_DIR at a bigger disk, then raise REQUIRED_SPACE_GB so the next shortfall is caught before tar starts writing.
Another backup still running after 5s (lock: /run/system-backup.lock)
A run that finds the lock taken waits up to LOCK_WAIT_SECONDS for it, then gives up with exit code 1. We started the 45-second monthly xz run and then a daily run with the default wait: the daily run queued for 40 seconds and finished normally. A second daily run with LOCK_WAIT_SECONDS=5 in its profile gave up instead:
[2026-10-17 20:25:00] [INFO] Loading configuration from /tmp/shortwait.conf
[2026-10-17 20:25:05] [ERROR] Another backup still running after 5s (lock: /run/system-backup.lock)
If you see this with the default 30-minute wait, a run is either hung or far slower than it should be. Look for a stuck tar with pgrep -a tar. The lock is held through file descriptor 9 and released by the kernel when the process exits, so a stale lock file left in /run never blocks the next run.
tar: /etc/hostname: Not found in archive
You asked for a member with a leading slash. tar strips it when the archive is created, so list or extract etc/hostname instead. When you’re unsure of the exact name, sudo tar -tf "$ARCHIVE" | grep hostname prints it as stored.
Retention has one edge worth knowing too. find -mtime +30 only matches files at least 31 full days old, so in our test an archive dated exactly 30 days back stayed while the 31-day and 45-day ones were removed. With daily runs, RETENTION_DAYS=30 therefore keeps roughly 31 archives on disk. The exact count drifts by one either way, because the randomized start time and the archive’s finish time both move the file’s age against the cut-off, so size /backup for 32 to be safe.
Compare gzip, xz and zstd on real data
Archive sizes on a few MB of config files tell you nothing, so we benchmarked a 266 MB tree of /etc plus copies of /usr/share/doc and /usr/lib/python3, about 15,000 mixed text and binary files. Every row ran three times as root through tar on the 2 vCPU, 2 GB VM with no excludes, and the first run of each also warmed the page cache. Repeat runs were close, with the widest spread on default zstd at 1.21 to 1.44 seconds, and the table shows the medians.
| Compression | tar flag | Archive | Ratio | Create time | Extract time | Peak RAM |
|---|---|---|---|---|---|---|
| zstd (level 3) | --zstd | 60.0 MB | 4.4:1 | 1.3 s | 0.3 s | 45 MB |
| gzip | -z | 66.2 MB | 4.0:1 | 6.9 s | 0.9 s | 5 MB |
| bzip2 | -j | 51.5 MB | 5.2:1 | 20.2 s | 7.2 s | 9 MB |
| xz | -J | 44.4 MB | 6.0:1 | 47.3 s | 1.0 s | 264 MB |
| zstd -10, all cores | -I 'zstd -T0 -10' | 50.5 MB | 5.3:1 | 4.5 s | 0.3 s | 139 MB |
| zstd -19, all cores | -I 'zstd -T0 -19' | 44.7 MB | 6.0:1 | 74.2 s | 0.4 s | 357 MB |
zstd at its default level is the clear pick for a nightly job, because it was five times faster than gzip, 9% smaller, and almost three times faster to extract. On size, xz produced archives about 26% smaller than zstd, but it needed 47 seconds and 264 MB of RAM. The xz on this system already uses every core by default (xz --help lists threads=0), so you won’t get much more speed out of it.
Meanwhile bzip2 loses to zstd -T0 -10 on size, create time and extract time, and only wins on memory, which is why the script doesn’t default to it. Pushing zstd to level 19 matched xz’s size but took 74 seconds against xz’s 47, although it extracted three times faster. For a monthly archive where size matters most, xz is the better trade, and that’s what the monthly profile above uses.
This script is a good fit when you want plain tarballs any Linux box can open without extra tools, although it stops there. It doesn’t encrypt, deduplicate or ship archives off site. For those, Restic with S3 storage or BorgBackup with Borgmatic are the better tools. Our Borg vs Restic vs Kopia benchmark helps you choose between them. To push the finished tarballs to another host, pair this script with an rsync job on its own systemd timer. If your terminal is where you spend the day, Bash-it can add git-aware prompts without the startup penalty we measured once you trim its defaults.