Capstone: a backup job you can leave running

Options, dry-run, a per-job lock, verified archives, safe retention, scheduling and tests.

Beginner50 min · lesson 15 of 15
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (20 files, 7 KB): bo-capstone.tar.gz. Unpack it with tar -xzf bo-capstone.tar.gz, which creates bo-capstone/. SHA-256: bc67f52dc6fb6ee82d3120057fdd86059ea53eaf10987521d296f48a1adfff7a

This capstone builds a backup job you can schedule and leave running: backup.sh archives one directory without the secrets and logs it knows about, and proves that; publishes an archive only after checking it; deletes old archives of its own job only; refuses to rotate out good copies when the source suddenly shrinks; runs once at a time; and cleans up after every failure or signal, with an exit status a scheduler can act on. You will read it part by part, run it, push it down every failure path, test those paths with Bats and let cron run it once. The examples run in ~/bash-ops/backup.

What the job promises

The directory to back up is a small application, app/. Some of its files must never be archived: archives get copied to other machines and kept for months, and a password in one leaks wherever it goes. setup.sh creates the directory; every secret in it is a test value:

setup.sh
#!/usr/bin/env bash
# setup.sh: create app/, the sample application the backup job archives. Every secret in
# it is a test value. Run it once, in ~/bash-ops/backup.
set -Eeuo pipefail
mkdir -p app/.ssh app/config/tls app/data/uploads app/logs
printf 'listen=8443\nlog_level=info\n' > app/config/app.conf
printf 'DB_PASSWORD=lab-password-not-secret\n' > app/config/.env
printf 'DB_PASSWORD=lab-prod-password-not-secret\n' > app/config/.env.production
printf 'lab-certificate-not-secret\n' > app/config/tls/server.crt
printf 'lab-key-not-secret\n' > app/config/tls/server.key
printf 'lab-old-key-not-secret\n' > app/config/tls/OLD-SERVER.KEY
printf 'lab-ssh-key-not-secret\n' > app/.ssh/id_ed25519
printf '2026-09-27T08:00:01Z login ok user=alice token=lab-token-not-secret\n' > app/logs/app.log
printf '2026-09-26T08:00:01Z login ok user=bob token=lab-token-not-secret\n' > app/logs/app.log.1
# 2000 order lines, so the data is most of the archive (the size check relies on that)
seq -f '%g,9.50' 1001 3000 > 'app/data/orders 2026.csv'
printf 'invoice 1001\n' > 'app/data/uploads/invoice 1001.txt'
find app -type f | sort
deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ chmod +x setup.sh ./setup.sh
app/.ssh/id_ed25519 app/config/.env app/config/.env.production app/config/app.conf app/config/tls/OLD-SERVER.KEY app/config/tls/server.crt app/config/tls/server.key app/data/orders 2026.csv app/data/uploads/invoice 1001.txt app/logs/app.log app/logs/app.log.1

.env and .env.production hold passwords, the .key files (one in capitals) are private keys, .ssh/id_ed25519 is an SSH key, and the logs record tokens. server.crt is public and may be archived. Before writing code, write down what the job promises; each promise becomes a check in the script and a test in the suite:

One run of backup.sh
1parse and validate
bad options or values: status 2 before anything happens
2take this job's lock
another run of the job is active: status 3
3check the job's SOURCE
the name already belongs to another directory: status 2
4build in a work directory
inside DEST, so publishing is a rename
5verify
no excluded name inside; the checksum reads back; not suspiciously small
6publish, then prune
checksum first, then archive; delete this job's oldest beyond --keep
A failure or signal at any step before publish leaves DEST as it was.

The script, one part at a time

The script is about 260 lines, shown in nine parts. Save them in order, with a blank line between parts, as backup.sh (the lab checks that the joined parts are exactly the file it ran). The first part is the contract and the setup every other part relies on:

backup.sh, part 1 of 9
#!/usr/bin/env bash
# backup.sh: archive one directory into DEST as NAME-YYYYmmddTHHMMSSZ.tar.gz with a .sha256
# file, leaving out secrets and logs, and keep only the newest N archives of that job.
#
# Exit status:
# 0 archive published and verified, or --dry-run finished
# 1 the backup failed; nothing new was published (the log says why)
# 2 usage error: bad option or value, or NAME already belongs to another SOURCE in DEST
# 3 another run of this job holds its lock (3 several times in a row: a run is stuck)
# 129, 130, 143 stopped by SIGHUP, SIGINT or SIGTERM (128 + signal number); cleanup ran
# Under timeout(1), as the schedule runs it: 124 the time limit was reached and the run was
# stopped (cleanup ran); 137 it was killed after the grace period (a work.* directory may stay).
set -Eeuo pipefail
shopt -s inherit_errexit nullglob
umask 077 # archives hold your data: new files 0600, new directories 0700
# Never archived, compared in lower case: SSH keys, environment files, private keys and
# certificate bundles, logs and rotated logs. A denylist stops only the names it lists.
readonly EXCLUDES=('.ssh' 'id_rsa*' 'id_ecdsa*' 'id_ed25519*' '.env*'
'*.key' '*.pem' '*.p12' '*.pfx' '*.log' '*.log.*')
log() {
local stamp
stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
printf '%s backup.sh: %s\n' "$stamp" "$*" >&2
}
die() {
log "ERROR: $*"
exit 1
}
trap 'log "ERROR: line $LINENO: $BASH_COMMAND failed with status $?"' ERR

The exit-status list is the contract from "Command-line options": a scheduler can treat 3 (a run is still busy) differently from 1 (look at it now), and a repeated 3 means a stuck run. Then come the strict-mode header, nullglob (retention needs it) and umask 077 from "Temp files, locks and timeouts", so every file the script creates is private. readonly makes a variable a constant: a later assignment is an error. EXCLUDES is the one list of names never to archive; tar and the verification both read it, and its comment says the honest part: a denylist stops only the names it lists, so the safer design keeps secrets outside the directory you back up.

backup.sh, part 2 of 9
usage() {
cat << 'END'
Usage: backup.sh [OPTIONS] [--] SOURCE
Archive the directory SOURCE into DEST without secrets or logs, verify the
archive, publish it, and keep the newest N archives of this job.
-d, --dest DIR where archives go (default ~/backups); must already exist
--name NAME job name used in archive names (default: the last part of
SOURCE's path); in one DEST, a NAME belongs to one SOURCE
-k, --keep N archives of this job to keep, 2 to 100 (default 7)
--allow-shrink publish although the archive is less than half the size
of the previous one (after you have checked why)
-n, --dry-run show what would be archived and deleted; change nothing
-h, --help show this help and exit
Exit status: 0 done, 1 backup failed, 2 usage error, 3 another run of this
job holds the lock, 129/130/143 stopped by a signal. Under timeout(1) also
124 (time limit reached) and 137 (killed).
END
}
usage_error() {
printf 'backup.sh: %s\n' "$1" >&2
printf "Try 'backup.sh --help' for more information.\n" >&2
exit 2
}
backup.sh, part 3 of 9
parse_args() {
dest=$HOME/backups
name=
keep=7
allow_shrink=0
dry_run=0
local -a sources=()
while (($# > 0)); do
case $1 in
-h | --help)
usage
exit 0
;;
-n | --dry-run) dry_run=1 ;;
--allow-shrink) allow_shrink=1 ;;
-d | --dest | --name | -k | --keep)
(($# >= 2)) || usage_error "option $1 needs a value"
case $1 in
-d | --dest) dest=$2 ;;
--name) name=$2 ;;
*) keep=$2 ;;
esac
shift
;;
--dest=*) dest=${1#--dest=} ;;
--name=*) name=${1#--name=} ;;
--keep=*) keep=${1#--keep=} ;;
--)
shift
sources+=("$@")
break
;;
-?*) usage_error "unknown option: $1" ;;
*) sources+=("$1") ;;
esac
shift
done
# At least 2: when the newest archive turns out to be bad, an older one is still there.
if ! [[ $keep =~ ^[1-9][0-9]{0,2}$ ]] || ((keep < 2 || keep > 100)); then
usage_error "--keep must be a whole number from 2 to 100, not '$keep'"
fi
((${#sources[@]} == 1)) || usage_error "give exactly one SOURCE directory"
source_arg=${sources[0]}
}

Parts 2 and 3 are the option handling from "Command-line options". The three options that take a value share one check for the missing value. --keep must be at least 2, so that when the newest archive turns out to be bad, an older one still exists. local -a declares a local array. The loop sets dest, name, keep and the flags without local, on purpose: the later functions read them.

backup.sh, part 4 of 9
validate() {
[[ -d $source_arg ]] || die "not a directory: $source_arg"
[[ -d $dest ]] || die "destination does not exist: $dest (create it once with mkdir -m 700)"
src=$(realpath -e -- "$source_arg")
dest=$(realpath -e -- "$dest")
[[ $src != / ]] || usage_error "refusing to back up /"
if [[ $dest == "$src" || $dest == "$src"/* ]]; then
usage_error "--dest must be outside SOURCE, or tar would read its own archive"
fi
name=${name:-${src##*/}}
# The valid_name allowlist from "Functions, local and logging".
if ! [[ $name =~ ^[A-Za-z0-9_][A-Za-z0-9._-]*$ ]]; then
usage_error "cannot use '$name' as a job name; give --name"
fi
jobdir=$dest/.$name.job # this job's lock, its SOURCE record and its work directories
archive=$name-$(date -u +%Y%m%dT%H%M%SZ).tar.gz
# One list of tar options for the real archive and for the --dry-run listing.
tar_opts=(--sort=name --ignore-case)
local pattern
for pattern in "${EXCLUDES[@]}"; do
tar_opts+=("--exclude=$pattern")
done
tar_opts+=(-C "$(dirname -- "$src")" -- "${src##*/}")
}

validate fails with 1 if the source or the destination is missing, and turns both into absolute paths with realpath -e, which resolves .. and symbolic links and fails for a path that does not exist. That makes the next check reliable: a destination inside the source is refused. The job name defaults to the source's last path component and must pass the valid_name allowlist from "Functions, local and logging". The tar options are built once, as an array: --ignore-case makes each --exclude after it match in any case, -C changes into the parent directory, and -- comes before the source name.

backup.sh, part 5 of 9
work=
cleanup() {
if [[ -n $work ]]; then rm -rf -- "$work"; fi
}
on_signal() {
log "caught SIG$1, cleaning up"
trap - "$1"
kill -s "$1" "$$"
}
# One lock per job, inside DEST: every run that writes this job's files, whoever starts it,
# takes the same lock. Other jobs and other destinations are not blocked by it.
take_lock() {
mkdir -p -- "$jobdir"
exec 9> "$jobdir/lock"
if ! flock -n 9; then
log "another run of job $name holds $jobdir/lock"
exit 3
fi
}
# In one DEST a job name belongs to one SOURCE: the first run records it, later runs
# compare. Otherwise /srv/site1/app and /srv/site2/app would prune each other's archives.
check_source() {
local recorded
if [[ -f $jobdir/source ]]; then
recorded=$(cat -- "$jobdir/source")
if [[ $recorded != "$src" ]]; then
usage_error "job '$name' in $dest backs up $recorded; give $src another --name"
fi
elif ((!dry_run)); then
printf '%s\n' "$src" > "$jobdir/source"
fi
}

cleanup and on_signal come from "Traps, signals and cleanup", and take_lock is the flock pattern from nightly.sh. Where the lock lives is a decision: in DEST/.NAME.job/, every run that writes this job's files takes the same lock, whoever starts it, while other jobs and destinations run freely. check_source gives the job an identity: the first run records the source path, and a later run with the same name but another source is refused with 2.

backup.sh, part 6 of 9
build_archive() {
log "archiving $src as job $name"
tar -czf "$work/$archive" "${tar_opts[@]}" || die "tar failed; nothing was published"
}
verify_archive() {
local entry part pattern
local -a parts
tar -tzf "$work/$archive" > "$work/listing" || die "cannot read back $archive"
while IFS= read -r entry; do
IFS=/ read -r -a parts <<< "$entry"
for part in "${parts[@]}"; do
for pattern in "${EXCLUDES[@]}"; do
# ${part,,} is the name in lower case: tar --ignore-case compared it that way too.
# shellcheck disable=SC2053 # $pattern is unquoted on purpose: it is a glob
if [[ ${part,,} == $pattern ]]; then die "excluded name inside the archive: $entry"; fi
done
done
done < "$work/listing"
(cd -- "$work" && sha256sum -- "$archive" > "$archive.sha256" &&
sha256sum --check --quiet -- "$archive.sha256") || die "checksum verification failed"
}

build_archive writes the archive into the work directory. Its || die matters: under set -e a failed tar would also stop the script, but with tar's own status, 2 for a fatal error, which this contract reserves for "called wrongly". GNU tar exits 1 when a file changed while it was read; the job treats that as a failure too.

verify_archive checks the archive as a restore would see it. tar -tzf reads the whole file, so a damaged gzip stream fails here. Its listing goes into a file first, because feeding the loop with < <( ) would lose tar's exit status, even under strict mode:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ bash -c 'set -euo pipefail while IFS= read -r entry; do echo "$entry"; done < <(tar -tzf missing.tar.gz) echo "loop done, status $?"'
tar (child): missing.tar.gz: Cannot open: No such file or directory tar (child): Error is not recoverable: exiting now tar: Child returned status 2 tar: Error is not recoverable: exiting now loop done, status 0

tar failed, the loop saw no input, and the shell went on with status 0. With the listing in a file, || die sees tar's status. (When you do need < <( ), the advanced lesson "Processes, pipes and file descriptors in real scripts" shows how to collect its status with wait "$!".) Each path is split at /, and every component, in lower case (${part,,}, Bash-only), is compared with every pattern; the unquoted right side of == makes it a glob match, and SC2053 is silenced with that reason. sha256sum --check then proves the checksum reads back.

backup.sh, part 7 of 9
# An archive much smaller than the last one usually means SOURCE lost its data (a volume
# that did not mount, a wiped directory). Publishing it would start rotating out good copies.
check_size() {
local -a old=("$dest/$name"-????????T??????Z.tar.gz)
((${#old[@]} > 0)) || return 0 # the first archive of this job
local last=${old[-1]} new_size last_size
new_size=$(stat -c %s -- "$work/$archive")
last_size=$(stat -c %s -- "$last")
if ((new_size * 2 < last_size && !allow_shrink)); then
die "new archive: $new_size bytes, ${last##*/}: $last_size; check SOURCE (--allow-shrink once if expected)"
fi
}
publish() {
sync -- "$work/$archive" "$work/$archive.sha256" # the data on disk before the names appear
# work is inside DEST, so each mv is a rename within one file system: atomic.
# The checksum goes first, so every archive a reader can see has its .sha256.
mv -- "$work/$archive.sha256" "$dest/"
mv -- "$work/$archive" "$dest/"
log "published $dest/$archive"
printf '%s\n' "$dest/$archive"
}

check_size compares the new archive with this job's newest (${old[-1]} is the array's last element; stat -c %s prints a size in bytes). Less than half the size usually means the source lost its data, so the run fails with 1 unless you pass --allow-shrink after checking why. publish flushes both files to disk with sync, then renames them within one file system, so a reader never sees half an archive (durable renames across a power cut are an advanced-course topic). The checksum goes first, so every visible archive has its .sha256; a signal between the two mv commands can leave a checksum alone, which a restore job must skip.

backup.sh, part 8 of 9
prune() {
local -a old=()
local path i excess
# This job's archives only (the timestamp has a fixed length), never the one just made.
for path in "$dest/$name"-????????T??????Z.tar.gz; do
if [[ $path != "$dest/$archive" ]]; then old+=("$path"); fi
done
if ((${#old[@]} > 0)) && [[ ${old[-1]} > "$dest/$archive" ]]; then
log "WARNING: ${old[-1]##*/} is dated after this run; check the clock"
fi
# Names sort oldest first: delete from the front until KEEP remain, this run's included.
excess=$((${#old[@]} + 1 - keep))
for ((i = 0; i < excess; i++)); do
if ((dry_run)); then
printf 'would delete %s\n' "${old[i]}"
else
rm -f -- "${old[i]:?}" "${old[i]:?}.sha256"
log "deleted ${old[i]}"
fi
done
}

prune collects this job's archives except the one just published. ????????T??????Z matches only a timestamp of that exact length, so app-data-... or site2-app-... never match. The names sort oldest first; an archive dated after this run (> in [[ ]] compares text) means a wrong clock, and the job warns. for ((i = 0; i < excess; i++)) is Bash's arithmetic loop: start at 0, run while i < excess, add 1 after each pass. ${old[i]:?} keeps an empty name away from rm.

backup.sh, part 9 of 9
main() {
parse_args "$@"
validate
if ((dry_run)); then
check_source
printf 'would archive into %s/%s:\n' "$dest" "$archive"
tar -cvf /dev/null "${tar_opts[@]}" || die "cannot read $src"
prune
return 0
fi
trap cleanup EXIT
trap 'on_signal HUP' HUP
trap 'on_signal INT' INT
trap 'on_signal TERM' TERM
take_lock
check_source
[[ ! -e $dest/$archive ]] || die "$dest/$archive already exists; run again in a second"
work=$(mktemp -d -- "$jobdir/work.XXXXXX")
build_archive
verify_archive
check_size
publish
prune
log "done: keeping the newest $keep archives of job $name"
}
main "$@"

Everything runs from main, called on the last line with "$@", the script's arguments unchanged, so Bash has read every function before any of them runs. A dry run returns before any trap, lock or directory exists; it lists the plan with tar -cvf /dev/null and the same tar_opts (GNU tar does not read file contents for /dev/null, so it notices an unreadable directory but not an unreadable file). The traps are set before the lock, so a signal at any later moment is handled. The project keeps .shellcheckrc and .editorconfig from "Check your script":

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ chmod +x backup.sh shellcheck backup.sh test/backup.bats && shfmt -d backup.sh test/backup.bats && echo "shellcheck and shfmt: clean"
shellcheck and shfmt: clean
$ ./backup.sh --help echo "status: $?"
Usage: backup.sh [OPTIONS] [--] SOURCE Archive the directory SOURCE into DEST without secrets or logs, verify the archive, publish it, and keep the newest N archives of this job. -d, --dest DIR where archives go (default ~/backups); must already exist --name NAME job name used in archive names (default: the last part of SOURCE's path); in one DEST, a NAME belongs to one SOURCE -k, --keep N archives of this job to keep, 2 to 100 (default 7) --allow-shrink publish although the archive is less than half the size of the previous one (after you have checked why) -n, --dry-run show what would be archived and deleted; change nothing -h, --help show this help and exit Exit status: 0 done, 1 backup failed, 2 usage error, 3 another run of this job holds the lock, 129/130/143 stopped by a signal. Under timeout(1) also 124 (time limit reached) and 137 (killed). status: 0

Run it: dry run, real run, retention

Create the destination once, with mode 700, and ask for a dry run first:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ mkdir -m 700 backups ./backup.sh --dry-run --dest backups app echo "status: $?" ls -A backups
would archive into /home/deploy/bash-ops/backup/backups/app-20260928T172349Z.tar.gz: app/ app/config/ app/config/app.conf app/config/tls/ app/config/tls/server.crt app/data/ app/data/orders 2026.csv app/data/uploads/ app/data/uploads/invoice 1001.txt app/logs/ status: 0

The plan comes from tar itself with the real options: no .ssh, no .env file, no .key in either case, no log, rotated or not; server.crt is in. backups/ is still empty. Now the real run:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ ./backup.sh --dest backups app echo "status: $?" ls -lA backups backups/.app.job cat backups/.app.job/source
2026-09-28T17:23:49Z backup.sh: archiving /home/deploy/bash-ops/backup/app as job app 2026-09-28T17:23:49Z backup.sh: published /home/deploy/bash-ops/backup/backups/app-20260928T172349Z.tar.gz /home/deploy/bash-ops/backup/backups/app-20260928T172349Z.tar.gz 2026-09-28T17:23:49Z backup.sh: done: keeping the newest 7 archives of job app status: 0 backups: total 16 drwx------ 2 deploy deploy 4096 Sep 28 17:23 .app.job -rw------- 1 deploy deploy 4519 Sep 28 17:23 app-20260928T172349Z.tar.gz -rw------- 1 deploy deploy 94 Sep 28 17:23 app-20260928T172349Z.tar.gz.sha256 backups/.app.job: total 4 -rw------- 1 deploy deploy 0 Sep 28 17:23 lock -rw------- 1 deploy deploy 33 Sep 28 17:23 source /home/deploy/bash-ops/backup/app

The path of the new archive appears on standard output between the log lines, both files are -rw------- thanks to umask 077, and .app.job holds the lock file and the recorded source. Look inside the archive and check it the way a restore should. The parentheses run the cd in a subshell, so your shell stays in ~/bash-ops/backup:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ tar -tzf backups/app-*.tar.gz (cd backups && sha256sum --check -- app-*.tar.gz.sha256)
app/ app/config/ app/config/app.conf app/config/tls/ app/config/tls/server.crt app/data/ app/data/orders 2026.csv app/data/uploads/ app/data/uploads/invoice 1001.txt app/logs/ app-20260928T172349Z.tar.gz: OK

Two more runs with --keep 2, then a dry run, each a second after the one before. The names must differ: a dry run in the same second as the last run would plan that run's own name, which retention never deletes, and list nothing.

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ sleep 1; ./backup.sh --keep 2 --dest backups app > /dev/null sleep 1; ./backup.sh --keep 2 --dest backups app > /dev/null ls backups sleep 1; ./backup.sh --dry-run --keep 2 --dest backups app | grep '^would delete' ls backups | wc -l
2026-09-28T17:23:50Z backup.sh: archiving /home/deploy/bash-ops/backup/app as job app 2026-09-28T17:23:50Z backup.sh: published /home/deploy/bash-ops/backup/backups/app-20260928T172350Z.tar.gz 2026-09-28T17:23:50Z backup.sh: done: keeping the newest 2 archives of job app 2026-09-28T17:23:51Z backup.sh: archiving /home/deploy/bash-ops/backup/app as job app 2026-09-28T17:23:51Z backup.sh: published /home/deploy/bash-ops/backup/backups/app-20260928T172351Z.tar.gz 2026-09-28T17:23:51Z backup.sh: deleted /home/deploy/bash-ops/backup/backups/app-20260928T172349Z.tar.gz 2026-09-28T17:23:51Z backup.sh: done: keeping the newest 2 archives of job app app-20260928T172350Z.tar.gz app-20260928T172351Z.tar.gz app-20260928T172350Z.tar.gz.sha256 app-20260928T172351Z.tar.gz.sha256 would delete /home/deploy/bash-ops/backup/backups/app-20260928T172350Z.tar.gz 4

The third run deleted the oldest archive and its checksum. The dry run shows that the next run would delete the older of the two remaining archives, and it changed nothing: there are still four files.

Every failure path

A job you leave running is judged by its failures. First, command-line mistakes:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ ./backup.sh --keep 1 app echo "status: $?" ./backup.sh --dest app/data app echo "status: $?" ./backup.sh --dest backups app backups echo "status: $?" ./backup.sh --dest backups missing echo "status: $?"
backup.sh: --keep must be a whole number from 2 to 100, not '1' Try 'backup.sh --help' for more information. status: 2 backup.sh: --dest must be outside SOURCE, or tar would read its own archive Try 'backup.sh --help' for more information. status: 2 backup.sh: give exactly one SOURCE directory Try 'backup.sh --help' for more information. status: 2 2026-09-28T17:23:52Z backup.sh: ERROR: not a directory: missing status: 1

Three usage errors are rejected with 2 before the script touches anything. A missing source is 1: the command was right, the machine was not. Now a real failure halfway through. A subdirectory nobody can read makes tar fail after it has already written part of the archive:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ find backups | sort > before.txt mkdir app/private chmod 000 app/private ./backup.sh --dest backups app echo "status: $?" find backups | sort | diff before.txt - && echo "backups/ unchanged" chmod 700 app/private && rmdir app/private
2026-09-28T17:23:53Z backup.sh: archiving /home/deploy/bash-ops/backup/app as job app tar: app/private: Cannot open: Permission denied tar: Exiting with failure status due to previous errors 2026-09-28T17:23:53Z backup.sh: ERROR: tar failed; nothing was published status: 1 backups/ unchanged

die logged the reason and exited 1, and the EXIT trap removed the work directory with the partial archive in it: find lists exactly what it listed before. The verification exists for the day someone breaks the exclusion. This sed makes such a copy, replacing the line that adds the --exclude options with :, a command that does nothing:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ sed 's/tar_opts+=("--exclude=$pattern")/:/' backup.sh > backup-noexclude.sh chmod +x backup-noexclude.sh ./backup-noexclude.sh --dest backups app echo "status: $?" find backups | sort | diff before.txt - && echo "backups/ unchanged"
2026-09-28T17:23:53Z backup.sh: archiving /home/deploy/bash-ops/backup/app as job app 2026-09-28T17:23:53Z backup.sh: ERROR: excluded name inside the archive: app/.ssh/ status: 1 backups/ unchanged

tar archived everything, and the verification stopped the run at the first excluded name, the .ssh directory, with status 1 and nothing published. Next, the day the data volume does not mount: app/data is suddenly an empty directory.

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ mv app/data data.aside && mkdir app/data ./backup.sh --dest backups app echo "status: $?" find backups | sort | diff before.txt - && echo "backups/ unchanged" rmdir app/data && mv data.aside app/data
2026-09-28T17:23:53Z backup.sh: archiving /home/deploy/bash-ops/backup/app as job app 2026-09-28T17:23:53Z backup.sh: ERROR: new archive: 292 bytes, app-20260928T172351Z.tar.gz: 4519; check SOURCE (--allow-shrink once if expected) status: 1 backups/ unchanged

The new archive was under 300 bytes against about 4500 for the last one, so the job refused to publish it, and the two good archives stay. Without this check, two such runs with --keep 2 would have replaced both with archives of nothing, each run reporting 0. Now a second site whose directory is also called app, backed up into the same backups/:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ mkdir -p site2/app && printf 'site 2\n' > site2/app/index.html ./backup.sh --keep 2 --dest backups site2/app echo "status: $?" ./backup.sh --keep 2 --dest backups --name site2-app site2/app > /dev/null echo "status: $?" ls -A backups
backup.sh: job 'app' in /home/deploy/bash-ops/backup/backups backs up /home/deploy/bash-ops/backup/app; give /home/deploy/bash-ops/backup/site2/app another --name Try 'backup.sh --help' for more information. status: 2 2026-09-28T17:23:53Z backup.sh: archiving /home/deploy/bash-ops/backup/site2/app as job site2-app 2026-09-28T17:23:53Z backup.sh: published /home/deploy/bash-ops/backup/backups/site2-app-20260928T172353Z.tar.gz 2026-09-28T17:23:53Z backup.sh: done: keeping the newest 2 archives of job site2-app status: 0 .app.job app-20260928T172351Z.tar.gz .site2-app.job app-20260928T172351Z.tar.gz.sha256 app-20260928T172350Z.tar.gz site2-app-20260928T172353Z.tar.gz app-20260928T172350Z.tar.gz.sha256 site2-app-20260928T172353Z.tar.gz.sha256

Under the name app its retention would have counted and deleted the first site's archives. The recorded source refused it with 2; with --name site2-app it has its own archives and its own .site2-app.job, and app's two archives are untouched.

Next, two runs at once, and signals. This needs a run that stays busy for a few seconds and a way to signal all of it. stop-run.sh does that, and because it is a script, it behaves the same from your prompt as in the lab:

stop-run.sh
#!/usr/bin/env bash
# stop-run.sh SIGNAL: start a slow backup of app/ into backups/, show that a second run is
# refused while it works, then send SIGNAL to the whole first run and show how it ended.
set -Eeuo pipefail
signal=${1:?usage: stop-run.sh HUP|TERM|KILL}
# Sparse: 2 GiB that take no disk space, but tar needs seconds to read the zeros.
truncate -s 2G app/data/big.img
trap 'rm -f -- app/data/big.img' EXIT
# A script has no job control, so a background command stays in the script's process
# group. setsid starts the backup in a new group whose ID is its PID, $!.
setsid ./backup.sh --dest backups app 2> first.err &
pid=$!
# Wait until tar is running, or until the backup has already ended (at most 10 s).
timeout 10 bash -c "until grep -q archiving first.err || ! kill -0 $pid 2> /dev/null; do
sleep 0.1
done" || true
if ! grep -q archiving first.err; then
# It failed before tar, or hangs: stop what is left, then report the backup's own error.
kill -KILL -- "-$pid" 2> /dev/null || true
status=0
wait "$pid" || status=$?
cat first.err >&2
echo "stop-run.sh: the backup ended before it started tar: status $status" >&2
exit "$status"
fi
status=0
./backup.sh --dest backups app || status=$?
echo "second run: status $status"
kill -s "$signal" -- "-$pid" # the minus sign: every process in the group, tar included
status=0
wait "$pid" || status=$?
echo "first run: status $status"
cat first.err

truncate -s sets a file's size without writing data. setsid starts a command in a new process group, so kill -- -PID reaches the backup and its tar. At an interactive prompt, job control already gives every background job its own group, and setsid would fork, making $! the wrong PID; a script has no job control, which is why the demo is one. It offers no SIGINT: a script starts background commands with SIGINT ignored ("Traps, signals and cleanup"). If the backup ends before it starts tar (the lock is held, or the archive name is taken because a run finished in the same second), the wait loop stops at once, and stop-run.sh prints the backup's own log and exits with its status.

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ find backups | sort > before.txt chmod +x stop-run.sh ./stop-run.sh TERM find backups | sort | diff before.txt - && echo "backups/ unchanged"
2026-09-28T17:23:53Z backup.sh: another run of job app holds /home/deploy/bash-ops/backup/backups/.app.job/lock second run: status 3 first run: status 143 2026-09-28T17:23:53Z backup.sh: archiving /home/deploy/bash-ops/backup/app as job app Terminated tar -czf "$work/$archive" "${tar_opts[@]}" 2026-09-28T17:23:53Z backup.sh: caught SIGTERM, cleaning up backups/ unchanged
deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ ./stop-run.sh HUP find backups | sort | diff before.txt - && echo "backups/ unchanged"
2026-09-28T17:23:54Z backup.sh: another run of job app holds /home/deploy/bash-ops/backup/backups/.app.job/lock second run: status 3 ./stop-run.sh: line 31: 286000 Hangup setsid ./backup.sh --dest backups app 2> first.err first run: status 129 2026-09-28T17:23:53Z backup.sh: archiving /home/deploy/bash-ops/backup/app as job app Hangup tar -czf "$work/$archive" "${tar_opts[@]}" 2026-09-28T17:23:54Z backup.sh: caught SIGHUP, cleaning up backups/ unchanged

The second run found the lock held and exited 3. In the first run's log, Terminated with the tar command is Bash reporting that its tar child was killed; the handler then logged caught SIGTERM and re-raised it, and the EXIT trap removed the work directory: 143, backups/ unchanged. SIGHUP, sent when the SSH session that started a run closes, gives 129 the same way (the Hangup line with a PID is stop-run.sh reporting how its background job ended). To stop a run by hand, find its group with ps -o pid,pgid,args -u "$USER" and run kill -TERM -- -PGID; a signal to the script's PID alone waits until the current tar finishes.

A run can also hang: tar on a stuck network mount never returns, and the job would hold its lock, so every later run exits 3, forever. A time limit around the whole run fixes that. timeout sends SIGTERM to the run's process group when the time is up:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ truncate -s 2G app/data/big.img timeout -k 10 1 ./backup.sh --dest backups app echo "status: $?" rm app/data/big.img find backups | sort | diff before.txt - && echo "backups/ unchanged" flock -n backups/.app.job/lock echo "the lock is free"
2026-09-28T17:23:54Z backup.sh: archiving /home/deploy/bash-ops/backup/app as job app Terminated tar -czf "$work/$archive" "${tar_opts[@]}" 2026-09-28T17:23:55Z backup.sh: caught SIGTERM, cleaning up status: 124 backups/ unchanged the lock is free

After one second the handler ran, cleanup removed the work directory, and timeout reported 124; the lock is free for the next run. With -k, a run that ignores SIGTERM is killed later and reports 137, and it can leave a work.* directory behind (see Try this). Last, the hostile name from "Quoting, word splitting and globbing", a source directory called -rf with spaces inside it:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ mkdir -p -- '-rf/sub dir' printf 'keep me\n' > '-rf/sub dir/my notes.txt' ./backup.sh --dest backups -rf echo "status: $?" ./backup.sh --dest backups -- -rf echo "status: $?" ./backup.sh --dest backups --name notes -- -rf > /dev/null echo "status: $?" tar -tzf backups/notes-*.tar.gz
backup.sh: unknown option: -rf Try 'backup.sh --help' for more information. status: 2 backup.sh: cannot use '-rf' as a job name; give --name Try 'backup.sh --help' for more information. status: 2 2026-09-28T17:23:55Z backup.sh: archiving /home/deploy/bash-ops/backup/-rf as job notes 2026-09-28T17:23:55Z backup.sh: published /home/deploy/bash-ops/backup/backups/notes-20260928T172355Z.tar.gz 2026-09-28T17:23:55Z backup.sh: done: keeping the newest 7 archives of job notes status: 0 -rf/ -rf/sub dir/ -rf/sub dir/my notes.txt

Without -- the name is an unknown option. With -- it is a directory, but -rf fails the name allowlist, so the job asks for --name. With both, the archive holds sub dir/my notes.txt with its spaces intact.

The tests

The Bats suite turns each run above into a test, with its own source tree and destination in $BATS_TEST_TMPDIR:

test/backup.bats, part 1 of 4
# backup.bats: tests for backup.sh, run from the project directory with: bats --tap test/
setup() {
script=$BATS_TEST_DIRNAME/../backup.sh
src=$BATS_TEST_TMPDIR/app
dest=$BATS_TEST_TMPDIR/backups
mkdir -p "$src/.ssh" "$src/config" "$src/logs" "$src/data" "$dest"
printf 'listen=8443\n' > "$src/config/app.conf"
printf 'DB_PASSWORD=lab-password-not-secret\n' > "$src/config/.env.production"
printf 'lab-key-not-secret\n' > "$src/config/SERVER.KEY"
printf 'lab-ssh-key-not-secret\n' > "$src/.ssh/id_ed25519"
printf 'login ok token=lab-token-not-secret\n' > "$src/logs/app.log.1"
seq -f '%g,9.50' 1001 3000 > "$src/data/orders 2026.csv"
}
teardown() {
# Stop a slow run that a failed test left behind, then make everything removable.
if [[ -n ${pid:-} ]]; then kill -KILL -- "-$pid" 2> /dev/null || true; fi
chmod -R u+rwX -- "$BATS_TEST_TMPDIR"
}
# Fails the test unless DEST holds no archive, checksum or work directory.
assert_nothing_published() {
run find "$dest" -name '*.tar.gz*' -o -name 'work.*'
[ "$status" -eq 0 ]
[ "$output" = "" ]
}
# Start a slow backup in its own process group; return once tar is running (at most 10 s).
# 3>&- closes the descriptor Bats reads results from, so a stray run cannot hold it.
start_slow_backup() {
local i
truncate -s 2G "$src/data/big.img" # sparse: tar reads 2 GiB of zeros, the disk stores none
setsid "$script" --dest "$dest" -- "$src" 2> "$BATS_TEST_TMPDIR/slow.err" 3>&- &
pid=$!
for ((i = 0; i < 100; i++)); do
if grep -q archiving "$BATS_TEST_TMPDIR/slow.err"; then return 0; fi
sleep 0.1
done
return 1
}

teardown runs after every test: it kills a slow run that a failing test left behind, then restores permissions so Bats can delete what a test made unreadable. assert_nothing_published checks with run find, so a deleted directory fails the check instead of passing it. start_slow_backup is stop-run.sh's first half as a function, with a wait loop of at most 100 tries.

test/backup.bats, part 2 of 4
@test "publishes a verified archive without secrets or logs" {
run "$script" --dest "$dest" -- "$src"
[ "$status" -eq 0 ]
run tar -tzf "$dest"/app-*.tar.gz
[[ $output == *"app/data/orders 2026.csv"* ]]
[[ ${output,,} != *".env"* && ${output,,} != *".key"* ]]
[[ $output != *".ssh"* && $output != *".log"* ]]
cd "$dest"
run sha256sum --check -- app-*.tar.gz.sha256
[ "$status" -eq 0 ]
}
@test "--dry-run lists the plan and creates nothing" {
run "$script" --dry-run --dest "$dest" -- "$src"
[ "$status" -eq 0 ]
[[ $output == *"app/config/app.conf"* && $output != *".env"* ]]
run find "$dest" -mindepth 1
[ "$status" -eq 0 ]
[ "$output" = "" ]
}
@test "usage errors exit 2" {
run "$script" --keep 1 --dest "$dest" -- "$src"
[ "$status" -eq 2 ]
run "$script" --dest "$src/data" -- "$src"
[ "$status" -eq 2 ]
[[ $output == *"--dest must be outside SOURCE"* ]]
}
@test "a tar failure publishes nothing and leaves no work directory" {
mkdir "$src/private"
chmod 000 "$src/private"
run "$script" --dest "$dest" -- "$src"
[ "$status" -eq 1 ]
[[ $output == *"tar failed"* ]]
assert_nothing_published
}
test/backup.bats, part 3 of 4
@test "a second run while the first holds the lock exits 3" {
start_slow_backup
run "$script" --dest "$dest" -- "$src"
kill -TERM -- "-$pid"
wait "$pid" || true
[ "$status" -eq 3 ]
[[ $output == *"holds $dest/.app.job/lock"* ]]
}
@test "SIGTERM mid-run exits 143 and leaves nothing behind" {
start_slow_backup
kill -TERM -- "-$pid"
st=0
wait "$pid" || st=$?
[ "$st" -eq 143 ]
assert_nothing_published
}
@test "an archive less than half the size of the last one is refused" {
run "$script" --dest "$dest" -- "$src"
[ "$status" -eq 0 ]
rm -- "$src/data/orders 2026.csv"
sleep 1 # a new second, so the new archive gets a new name
run "$script" --dest "$dest" -- "$src"
[ "$status" -eq 1 ]
[[ $output == *"check SOURCE"* ]]
run ls "$dest"
[ "${#lines[@]}" -eq 2 ] # the first archive and its checksum, nothing else
}
test/backup.bats, part 4 of 4
@test "two sources named app keep each other's archives" {
site2=$BATS_TEST_TMPDIR/site2/app
mkdir -p "$site2"
printf 'site 2\n' > "$site2/index.html"
for stamp in 20260101T000000Z 20260102T000000Z 20260103T000000Z; do
touch "$dest/site2-app-$stamp.tar.gz"
done
run "$script" --keep 2 --dest "$dest" -- "$src"
[ "$status" -eq 0 ]
run "$script" --keep 2 --dest "$dest" -- "$site2"
[ "$status" -eq 2 ]
[[ $output == *"backs up $src"* ]]
run "$script" --keep 2 --name site2-app --dest "$dest" -- "$site2"
[ "$status" -eq 0 ]
run find "$dest" -name 'app-*.tar.gz'
[ "${#lines[@]}" -eq 1 ] # the first job's archive survived the second job's pruning
run find "$dest" -name 'site2-app-*.tar.gz'
[ "${#lines[@]}" -eq 2 ]
}
@test "--keep 2 deletes the oldest archives of this job only" {
for stamp in 20260101T000000Z 20260102T000000Z 20260103T000000Z; do
touch "$dest/app-$stamp.tar.gz" "$dest/app-$stamp.tar.gz.sha256"
done
touch "$dest/app-data-20260101T000000Z.tar.gz" # another job's archive
run "$script" --keep 2 --dest "$dest" -- "$src"
[ "$status" -eq 0 ]
run ls "$dest"
[ "${#lines[@]}" -eq 5 ]
[[ $output == *"app-20260103T000000Z.tar.gz"* && $output != *"app-20260102T000000Z"* ]]
[[ $output == *"app-data-20260101T000000Z.tar.gz"* ]]
}
@test "a source named -rf needs --name, and names with spaces survive" {
mkdir -p "$BATS_TEST_TMPDIR/-rf/sub dir"
printf 'keep me\n' > "$BATS_TEST_TMPDIR/-rf/sub dir/my notes.txt"
cd "$BATS_TEST_TMPDIR"
run "$script" --dest "$dest" -- -rf
[ "$status" -eq 2 ]
run "$script" --dest "$dest" --name notes -- -rf
[ "$status" -eq 0 ]
run tar -tzf "$dest"/notes-*.tar.gz
[[ $output == *"-rf/sub dir/my notes.txt"* ]]
}

The SIGTERM test records the status with st=0; wait "$pid" || st=$?, because a bare wait returning 143 would end the test before its checks. The identity test plants three old archives for site2-app and checks that pruning them leaves app's archive alone.

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ bats --tap test/ echo "status: $?"
1..10 ok 1 publishes a verified archive without secrets or logs ok 2 --dry-run lists the plan and creates nothing ok 3 usage errors exit 2 ok 4 a tar failure publishes nothing and leaves no work directory ok 5 a second run while the first holds the lock exits 3 ok 6 SIGTERM mid-run exits 143 and leaves nothing behind ok 7 an archive less than half the size of the last one is refused ok 8 two sources named app keep each other's archives ok 9 --keep 2 deletes the oldest archives of this job only ok 10 a source named -rf needs --name, and names with spaces survive status: 0

Ten tests, all ok, in seconds: the slow runs are stopped as soon as tar starts, so run the suite after every change.

Schedule it, and what it does not do

cron runs a job at the times in its crontab line, with /bin/sh, a short PATH and no terminal, and it ignores the exit status. So the line uses absolute paths, puts the timeout around the run (6 hours, then SIGKILL 5 minutes later), sends both output streams to a log with >> file 2>&1 ("Input, output and redirection"), and writes the status into the log itself. Save it as backup.cron. * * * * * means every minute, so you can watch one run:

backup.cron
* * * * * timeout -k 5m 6h "$HOME/bash-ops/backup/backup.sh" --dest "$HOME/bash-ops/backup/backups" "$HOME/bash-ops/backup/app" >> "$HOME/bash-ops/backup/backup.log" 2>&1; echo "backup.sh exit status $?" >> "$HOME/bash-ops/backup/backup.log"

crontab FILE would replace your whole crontab. This form adds the line to what is already there:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ cat backup.cron { crontab -l 2> /dev/null; cat backup.cron; } | crontab - echo "backup.sh lines in the crontab: $(crontab -l | grep -c backup.sh)"
… backup.sh lines in the crontab: 1
$ timeout 90 bash -c 'until grep -q "exit status" backup.log 2> /dev/null; do sleep 1; done' cat backup.log
2026-09-28T17:24:01Z backup.sh: archiving /home/deploy/bash-ops/backup/app as job app 2026-09-28T17:24:01Z backup.sh: published /home/deploy/bash-ops/backup/backups/app-20260928T172401Z.tar.gz /home/deploy/bash-ops/backup/backups/app-20260928T172401Z.tar.gz 2026-09-28T17:24:01Z backup.sh: done: keeping the newest 7 archives of job app backup.sh exit status 0

Within a minute cron ran the job, and the log shows the run and exit status 0. For real use, change * * * * * to a nightly time such as 15 2 * * * (02:15). To remove the line again, keep everything else:

deploy@web01:~/bash-ops/backup · Ubuntu 26.04 LTS
$ crontab -l | grep -v -F bash-ops/backup/backup.sh | crontab - echo "backup.sh lines in the crontab: $(crontab -l | grep -c backup.sh)"
backup.sh lines in the crontab: 0
Before you rely on it
Archives on the same host (often the same disk) as the data are a snapshot, not a backup. Three things are missing. An off-host copy, encrypted before it leaves the machine. A restore test: now and then, extract an archive into a scratch directory and compare it with the source; an archive nobody has restored is a hope. Alerting: cron discards the status, so something must watch backup.log and alert on any status but 0 and on a day without a new archive ("Observable jobs" in the advanced course). Also, tar of a live database directory is not a consistent copy: archive the database's own dump.

Try this

One failure is not handled yet: SIGKILL cannot be trapped, so a run killed with kill -9, or by timeout -k, leaves its work directory, partial archive included, in backups/.app.job/. Use a copy of the project in a new directory, with app/, backups/ and one finished run.

1. Reproduce it: ./stop-run.sh KILL, then ls -A backups/.app.job. Expect second run: status 3, first run: status 137, and lock source work.XXXXXX.

2. Add a function remove_leftovers that deletes every "$jobdir"/work.?????? directory with rm -rf -- "${stale:?}", logging removing leftover and the path, and call it in main right after take_lock. Explain why that position makes it safe: which runs can be using a work directory of this job, and what they must be holding? Why can it never delete another job's work?

3. Run ./backup.sh --dest backups app: expect the removing leftover line, status 0, and only lock source in ls -A backups/.app.job. Then add a Bats test that plants the leftover (mkdir -p "$dest/.app.job/work.abcdef" and a touch of a partial.tar.gz inside it), runs the script, and checks for status 0, removing leftover in $output, and exactly lock and source in ls -A "$dest/.app.job". Expect 11 tests ok, and ShellCheck and shfmt still clean.

Takeaway

Write down what a job promises (what it excludes, which files are its own, what each exit status means) and make the script prove each promise: verify what it built, refuse what looks wrong, publish by rename, delete only what its recorded identity owns, and clean up on every failure. Then test every failure path, and let the scheduler show you that it runs.

When a job needs structured data, HTTP APIs or retries, Python is the better tool, and the exit-status contract, data on standard output and logs on standard error, and the habit of testing failure paths come with you. Next: Python for security automation.

Quick check
01backup.sh creates its work directory with mktemp -d -- "$jobdir/work.XXXXXX", inside DEST. Why not use plain mktemp -d, which creates it in /tmp?
Incorrect — mktemp -d creates the directory with mode 0700, and umask 077 covers the files, so /tmp would not expose them.
Incorrect — The lock is on one open file and protects nothing by location; it only stops a second run of the same job into the same DEST.
Correct — /tmp is a tmpfs here. A rename within DEST's file system is atomic, so readers never see a half-copied archive.
Incorrect — umask only removes permission bits from what is created; mktemp -d works in /tmp under umask 077.
02A teammate removes || die "tar failed; nothing was published" from build_archive, reasoning that set -e stops the script anyway. What changes when tar cannot read a subdirectory?
Incorrect — set -e exits with the failing command's own status, and for a fatal tar error that is 2.
Correct — tar's fatal status is 2, and this script's contract uses 2 for "called wrongly". A caller would stop retrying a job that simply failed.
Incorrect — The EXIT trap runs on a set -e exit too, and it removes the work directory.
Incorrect — The ERR trap only logs here; it does not change the status the script exits with.
03Two jobs back up /srv/site1/app and /srv/site2/app into the same DEST, both without --name. The site1 job has run before. What happens on the site2 job's first run?
Correct — .app.job/source holds /srv/site1/app. Without that check both jobs would share the app-* names, and each would prune the other's archives.
Incorrect — That is what a job identified only by its last path component does; the recorded source stops the second job before it archives anything.
Incorrect — The lock only stops runs of the same job at the same time; it never merges archives, and flock -n does not wait.
Incorrect — The default name is only the last path component, app. A different name has to be given with --name.

Related