You have spent five lessons writing set -euo pipefail without knowing what it does. You have twice put a return 0 at the end of log() "because otherwise the script dies". And bookings_report.sh creates the report's directory and, if something fails halfway through writing it, leaves an incomplete file that somebody will read tomorrow as if it were sound. All of that gets fixed today, and it is worth understanding why it matters so much: a script that breaks early and makes a noise is infinitely better than one that fails halfway in silence. The first wakes you at four in the morning with a clear message; the second leaves you a truncated backup you will discover the day you need it. This lesson is about achieving the first: seeing what your script does, making it stop when it should, and guaranteeing that it leaves things clean whatever happens. At the end we will finally write backup_tramontana.sh.

Contents

  1. Debugging: seeing what the script really does
  2. shellcheck in earnest
  3. Strict mode pulled apart
  4. Where set -e does not save you
  5. trap and the pseudo-signals
  6. Guaranteed cleanup and Ctrl+C
  7. An ERR handler that is actually useful
  8. Idempotency
  9. Retries, safe temporary files and locking
  10. Application: backup_tramontana.sh

  1. Debugging: seeing what the script really does

Tool What it does
bash -n script.sh Checks the syntax without running anything
bash -x script.sh Traces each command already expanded, to stderr
set -x / set +x / PS4 Turns the trace on and off by region / formats the prefix
set -v Shows the lines before expanding them

bash -x is the main tool, and its default output (+ command) is not much use as soon as there are functions. With a decent PS4 it changes completely:

operator@srv-tramontana:~$ PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' \
>     bash -x ~/scripts/purge_releases.sh -n 2>&1 | head -5
+ purge_releases.sh:12:main: DRY_RUN=0
+ purge_releases.sh:20:main: readlink -f /opt/tramontana/app
+ purge_releases.sh:20:main: basename /opt/tramontana/releases/3.2.1
+ purge_releases.sh:20:main: active=3.2.1
+ purge_releases.sh:24:main: list+=(3.1.0)

Every line now says file, line number and function, and shows the command after expanding variables: you see basename /opt/tramontana/releases/3.2.1, not basename "$(readlink -f "$BASE/app")". That difference is exactly what you are after when a variable does not hold what you thought. Tracing a whole script generates hundreds of lines, so to narrow things down surround only the suspect region with set -x … set +x. Also export the PS4 in your ~/.bashrc so you do not have to type it every time. set -v is used far less, but it is the complement to -x when the problem is in the expansion itself: -v shows what you wrote and -x what Bash understood.

  1. shellcheck in earnest

You installed it back in 04-01. These are the warnings you will see most, with the reasons why:

Code What it says Why it really matters
SC2086 / SC2046 "Double quote to prevent globbing and word splitting", for variables and for $(command) rm $f with f="august report.txt" deletes two wrong files; with f="*" it deletes everything
SC2164 "Use cd ... || exit" If the cd fails, whatever comes next runs in the wrong directory
SC2155 "Declare and assign separately" local x=$(cmd) returns local's code, always 0: cmd's failure is lost

SC2155 deserves a demonstration, because it is subtle and it turns up everywhere:

operator@srv-tramontana:~$ f() { local v=$(false); echo "inside: $?"; }; f
inside: 0
operator@srv-tramontana:~$ g() { local v; v=$(false); echo "inside: $?"; }; g
inside: 1

In the first, false's error disappears and, with set -e, the script carries on happily with an empty variable; in the second it is detected. The rule: declare first, assign afterwards.

Silencing a warning is legitimate when you know what you are doing, but always with a written justification, and the directive affects only the next line (put it at the top of the file and it silences the warning throughout the whole script, which is almost never what you want):

# We want OPTIONS to be split into words: they are separate options.
# shellcheck disable=SC2086
rsync $OPTIONS "$src" "$dst"

  1. Strict mode pulled apart

set -euo pipefail is three independent settings, and it is worth knowing exactly what each one does.

  • set -e (errexit): if a command returns a non-zero code, the script ends immediately with that code. It turns silent failures into noisy stops.
  • set -u (nounset): using an undefined variable is a fatal error instead of expanding to the empty string. It prevents the classic rm -rf "$DIR/" turning into rm -rf / because DIR did not exist.
  • set -o pipefail: a pipeline returns the code of the last stage that failed, not that of the last stage; without it, cat nonexistent | wc -l returns 0 and your script believes everything went well.
operator@srv-tramontana:~$ bash -c 'cat /nonexistent | wc -l; echo "code: $?"'
cat: /nonexistent: No such file or directory
0
code: 0
operator@srv-tramontana:~$ bash -c 'set -o pipefail
> cat /nonexistent | wc -l; echo "code: $?"'
cat: /nonexistent: No such file or directory
0
code: 1

On set -u, two practical details. With Bash 4.4 and later — including the 5.2 on your Ubuntu 24.04 — an empty "$@" and "${array[@]}" no longer trigger the error, but ${array[0]} of an empty array does. And the emergency exit for any variable that may legitimately not exist is ${VAR:-}, which you have been using since 04-02: [[ -n ${TRAMONTANA_VERBOSE:-} ]] && echo "verbose mode".

  1. Where set -e does not save you

This is the material almost nobody tells you about and the reason people over-trust strict mode. set -e is disabled in five contexts, and in all of them a failure goes unnoticed:

set -e
# 1. In the condition of an if, while or until: that is its job, not a failure.
if grep -q something nonexistent_file; then :; fi   # does not abort (correct)
# 2. To the left of && or ||, and in any command list.
false && echo "nothing"                             # does not abort
check_something || echo "warned"                    # does not abort
# 3. With a leading !.
! false                                             # does not abort
# 4. INSIDE a function called in a condition: it is disabled throughout.
prepare() { cp /nonexistent /tmp/x; echo "still here"; }
if prepare; then :; fi                              # prints "still here"
# 5. In local/declare/export assignments with command substitution.
my_fn() { local v=$(false); echo "does not abort"; }

Case 4 is the most treacherous and deserves to be seen in action:

operator@srv-tramontana:~$ bash -c 'set -e
> prepare() { cp /nonexistent /tmp/x; echo "STILL RUNNING"; return 0; }
> if prepare; then echo "prepared"; fi'
cp: cannot stat '/nonexistent': No such file or directory
STILL RUNNING
prepared

cp failed, set -e was active, and the function carried on to the end and declared success, because using it as a condition makes Bash disable errexit throughout its body. It is fixed by checking inside (cp ... || return 1) or by not using the function as a condition: call it on its own and let set -e do its job. The practical conclusion: set -e is a safety net, not a plan. Check explicitly what matters — with || die, with if — and let set -e catch whatever slips through. And add the trap from 04-02: (( i++ )) with i at 0 returns 1 and does abort; use (( ++i )) or i=$(( i + 1 )).

  1. trap and the pseudo-signals

trap installs a handler that runs when a signal arrives. Its syntax is trap 'commands' SIGNAL... and, besides the signals from 03-06, it accepts four pseudo-signals:

Pseudo-signal Fires…
EXIT When the script ends, for whatever reason (including set -e)
ERR Every time a command fails, with the same exclusions as set -e
DEBUG / RETURN Before every command (very slow) / on returning from a function

EXIT is the important one: it is the only way to guarantee that something happens whatever occurs, because it fires on a normal exit, on a set -e failure, on a Ctrl+C and on a kill; only SIGKILL skips it, and there is no defence against that. Three details that avoid surprises: the handler's quotes decide when the variables are expanded (single quotes, at the moment it fires, which is nearly always what you want); trap - EXIT uninstalls a handler; and trap -p lists the installed ones.

  1. Guaranteed cleanup and Ctrl+C

The pattern that makes a script safe to interrupt:

TMP_DIR=""
cleanup() {
    local code=$?                      # it must be captured on the FIRST line
    [[ -n $TMP_DIR && -d $TMP_DIR ]] && rm -rf -- "$TMP_DIR"
    if (( code == 0 )); then log "finished correctly"
    else error "finished with code $code"; fi
    exit "$code"                       # keeps the original code
}
trap cleanup EXIT
trap 'error "interrupted by the user"; exit 130' INT TERM
TMP_DIR=$(mktemp -d) || die 73 "cannot create the temporary directory"

Four deliberate decisions. local code=$? goes on the first line, because any earlier command would overwrite it. The [[ -n $TMP_DIR && -d ... ]] check protects against the case where the script dies before creating the temporary directory: without it, an rm -rf "$TMP_DIR"/* with the variable empty would be catastrophic. exit "$code" preserves the original code instead of returning the rm's. And the INT handler exits with 130, which is 128 + 2 (SIGINT), the convention from 04-01; that exit 130 in turn fires the EXIT, so the cleanup runs anyway: the handlers chain, they do not override each other.

  1. An ERR handler that is actually useful

Bash publishes four variables during an ERR handler, and with them you can build a trace almost as useful as one from a language with exceptions:

Variable Content
$? / BASH_COMMAND The failure's code / the text of the command that was failing
BASH_LINENO[0] / FUNCNAME[@] The line of the current context / the function stack
error_trace() {
    local code=$?
    printf '[%s] ERROR %d in %s, line %s\n' \
        "$(date '+%F %T')" "$code" "${BASH_SOURCE[1]##*/}" "${BASH_LINENO[0]}" >&2
    printf '  command : %s\n' "$BASH_COMMAND" >&2
    printf '  stack   : %s\n' "${FUNCNAME[*]:1}" >&2   # :1 skips error_trace
    return "$code"
}
trap error_trace ERR
operator@srv-tramontana:~$ ~/scripts/backup_tramontana.sh
[2026-08-18 13:07:22] ERROR 1 in backup_tramontana.sh, line 61
  command : cp -a -- /home/operator/data/bookings.csv /srv/.../backup.9kQ2
  stack   : copy_sources main

In four lines you know the code, the file, the line, the exact already-expanded command and the chain of calls. Compare it with the bare cp: Permission denied you would otherwise have had and you will see why this function deserves to live in lib/common.sh.

  1. Idempotency

A script is idempotent if running it twice leaves the system the same as running it once. It is the most valuable design goal in the module, because it means you can relaunch it after a failure without thinking. The canonical counter-example, which Luis wrote last month:

# NOT idempotent: every run adds another line
echo "max_connections=200" >> /etc/tramontana/app.conf

After four runs, grep -c '^max_connections=' /etc/tramontana/app.conf returns 4. The application reads the last one and works, so nobody finds out... until somebody edits the first one and nothing happens. The idempotent version checks before acting:

set_option() {                      # set_option FILE KEY VALUE
    local file="$1" key="$2" value="$3"
    if grep -q "^${key}=" "$file"; then
        # Already there: it is replaced, with a dated backup as Module 2 requires.
        sudo sed -i.bak-"$(date +%F)" "s|^${key}=.*|${key}=${value}|" "$file"
    else
        printf '%s=%s\n' "$key" "$value" | sudo tee -a "$file" >/dev/null
    fi
}

operator@srv-tramontana:~$ set_option /etc/tramontana/app.conf max_connections 200
operator@srv-tramontana:~$ set_option /etc/tramontana/app.conf max_connections 200
operator@srv-tramontana:~$ grep -c '^max_connections=' /etc/tramontana/app.conf
1

Three transferable ideas: mkdir -p instead of mkdir (it does not fail if it exists), ln -sfn instead of ln -s (it replaces the link instead of nesting it inside) and checking the desired state before applying the change. It is the model Ansible works with, and it is why its output distinguishes ok from changed; we will come back to it in 07-06.

  1. Retries, safe temporary files and locking

Retries with exponential backoff

A network operation does not fail: it fails sometimes. Retrying immediately makes things worse, so you wait longer each time:

# retry ATTEMPTS INITIAL_WAIT COMMAND...
#   Returns: 0 if the command finishes well; 1 if the attempts run out.
retry() {
    local attempts="$1" wait="$2" n=1; shift 2
    until "$@"; do
        (( n >= attempts )) && { error "failed after $n attempts: $*"; return 1; }
        log "attempt $n failed; retrying in ${wait}s"
        sleep "$wait"
        wait=$(( wait * 2 )); (( ++n ))
    done
}
operator@srv-tramontana:~$ retry 4 2 curl -sf http://10.0.2.15:8080/health
[2026-08-18 13:11:02] attempt 1 failed; retrying in 2s
[2026-08-18 13:11:04] attempt 2 failed; retrying in 4s

The waits go 2, 4, 8, 16: that is exponential backoff. And never retry non-idempotent operations — creating a booking, sending an email — without an identifier that stops them being duplicated.

Safe temporary files

TMP="/tmp/backup_$$" is a real security hole: the PID is predictable, so an attacker can create a symbolic link with that name in advance pointing at /etc/tramontana/app.conf, and your script, if it runs with privileges, will overwrite the file it points to. TMP=$(mktemp -d) || die 73 "no temp directory" avoids it because it creates the file atomically (with O_EXCL, which fails if it already exists), with a random name and 600 permissions — 700 for directories. Always use it, together with the trap ... EXIT from section 6.

Locking inside the script

In 03-07 you put flock -n in front of the command in the crontab line. It is better for the lock to live inside the script, so that it also protects you when you launch it by hand:

exec 9>"$LOCKFILE" || die 73 "cannot open the lock" followed by flock -n 9 || die 75 "another backup is already running". exec 9>file opens descriptor 9 for the whole script and flock -n 9 tries to lock it without waiting. The lock is released only when the process dies, even under a kill -9, because it is held by the kernel and not by the file. Do not delete the lock file when you finish: that opens a race in which two processes could lock different files with the same name.

  1. Application: backup_tramontana.sh

Promised since Module 2, with everything from this lesson:

#!/usr/bin/env bash
#
# backup_tramontana.sh - Backup of the configuration, the data and the
#                        active release of Tramontana Bookings.
# Author : Systems operator <operator@srv-tramontana>  Date: 2026-08-18
# Usage  : backup_tramontana.sh [-h] [-v] [-n] [-d DEST]
# Exit   : 0 ok | 2 usage | 66 unreadable source | 69 missing dependency
#          73 cannot write | 75 another backup running | 130 interrupted

set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
readonly LOCKFILE="/var/lock/tramontana-backup.lock"
readonly APP_LINK="/opt/tramontana/app"
readonly SOURCES=(/home/operator/data/bookings.csv /etc/tramontana/app.conf)
DEST="${TRAMONTANA_DEST:-/srv/tramontana/backups/outgoing}"
DRY_RUN=0; TMP_DIR=""

cleanup() {                       # the handler from section 6, exactly as it was
    local code=$?
    [[ -n $TMP_DIR && -d $TMP_DIR ]] && rm -rf -- "$TMP_DIR"
    (( code == 0 )) && log "backup finished correctly"
    exit "$code"
}
trap cleanup EXIT
trap 'error "interrupted by the user"; exit 130' INT TERM
trap 'error "failure on line ${BASH_LINENO[0]}: ${BASH_COMMAND}"' ERR
# getopts ":hvnd:" as in 04-03: -v sets TRAMONTANA_VERBOSE=1, -n DRY_RUN=1,
# -d sets DEST, and anything else ends up in 'die 2'.
require_command tar gzip sha256sum || die 69 "missing dependencies"
mkdir -p "$DEST" || die 73 "cannot create $DEST"            # idempotent

# The lock comes before touching anything: if another backup is running, we
# leave without making a mess.
exec 9>"$LOCKFILE" || die 73 "cannot open $LOCKFILE"
flock -n 9 || die 75 "another backup is already running; aborting"
release=$(basename "$(readlink -f "$APP_LINK")")
today=$(date +%F)
archive="${DEST}/tramontana-${today}.tar.gz"
log "active release: $release, destination: $archive"
if (( DRY_RUN )); then
    printf 'Would copy %s and release %s -> %s\n' \
        "${SOURCES[*]}" "$release" "$archive"
    exit 0
fi
TMP_DIR=$(mktemp -d "${DEST}/.backup.XXXXXXXX") || die 73 "no temp directory"
for src in "${SOURCES[@]}"; do
    [[ -r $src ]] || die 66 "cannot read $src"
    cp -a -- "$src" "$TMP_DIR/"
    log "copied $src ($(format_bytes "$(stat -c %s "$src")"))"
done
cp -a -- "/opt/tramontana/releases/${release}" "${TMP_DIR}/release-${release}"
printf 'release=%s\ndate=%s\nhost=%s\n' "$release" "$today" "$(hostname)" \
    > "${TMP_DIR}/MANIFEST"
# It is written as .partial and renamed at the end: the final file only
# appears if tar finished cleanly, and mv within the same filesystem is
# atomic, just like the deployment's 'ln -sfn'.
tar -czf "${archive}.partial" -C "$TMP_DIR" .
mv -- "${archive}.partial" "$archive"
# app.conf contains db_password: 640 and group tramontana, never readable by
# others. It is the exact mistake that forced the password rotation in July.
sha256sum "$archive" > "${archive}.sha256"
chmod 640 "$archive" "${archive}.sha256"
tar -tzf "$archive" >/dev/null || die 1 "the generated archive is not readable"
log "backup verified: $(format_bytes "$(stat -c %s "$archive")")"
printf '%s\n' "$archive"          # stdout: the path, for chaining
exit 0
operator@srv-tramontana:~$ ~/scripts/backup_tramontana.sh -v
[2026-08-18 13:20:04] active release: 3.2.1, destination: .../tramontana-2026-08-18.tar.gz
[2026-08-18 13:20:04] copied /home/operator/data/bookings.csv (1.8 KiB)
[2026-08-18 13:20:04] copied /etc/tramontana/app.conf (412 B)
[2026-08-18 13:20:11] backup verified: 31.4 MiB
/srv/tramontana/backups/outgoing/tramontana-2026-08-18.tar.gz
operator@srv-tramontana:~$ ~/scripts/backup_tramontana.sh &   # and at the same time:
operator@srv-tramontana:~$ ~/scripts/backup_tramontana.sh; echo "code: $?"
[2026-08-18 13:20:14] ERROR: another backup is already running; aborting
code: 75

Test the failure path as well, because that is where the script proves its worth: interrupt it halfway with Ctrl+C and check that no .backup.XXXXXXXX and no .partial is left in the destination. Cleanup you have not tested is cleanup that does not work.

Common Mistakes and Tips

  • Believing set -e stops everything. It does not act in conditions, nor after &&/||, nor inside functions used as a condition, nor in local x=$(cmd): keep checking the important things by hand.
  • Not capturing $? on the first line of the EXIT handler. Any earlier command overwrites it and the script returns 0 even though it failed.
  • Using double quotes in the trap handler. The variables are expanded when the trap is installed, not when it fires: single quotes unless you know why. And do not delete the lock file when you finish, because it opens a race between processes: the lock is released by the kernel when the process dies.
  • mktemp without trap ... EXIT. Every failed run leaves rubbish behind: they always go together. Avoid predictable names in /tmp too, because $$ is not random: it is a symbolic-link attack waiting to happen.
  • Non-idempotent scripts. If relaunching duplicates a line or adds up twice, you cannot retry after a failure, which is precisely when you need to most.
  • Tip: write the final file under a temporary name and rename it at the end; it is the cheap way of making sure a half-written file never exists. And when something does not work, bash -x with PS4 before rereading the code: five seconds of trace save twenty minutes of guesswork.
  • Tip: test the error path, not only the success path: remove permissions, fill the disk, kill the process halfway. It is the only way to know whether your traps work.

Exercises

Exercise 1. This script looks correct and fails silently. Explain why and fix it in two ways.

#!/usr/bin/env bash
set -euo pipefail
prepare_dest() {
    mkdir -p /srv/tramontana/backups/new
    cp /etc/tramontana/does_not_exist.conf /srv/tramontana/backups/new/
    echo "destination prepared"
}
if prepare_dest; then
    echo "starting the backup..."
fi

Exercise 2. Add to lib/common.sh a function with_cleanup: it must create a temporary directory, publish it in TRAMONTANA_TMP, install the cleanup trap and return 0. Write a test case that verifies that, after a script dies through set -e, the directory no longer exists.

Exercise 3. Make this deployment fragment of Luis's idempotent and explain what problem each change solves: mkdir /opt/tramontana/releases/3.3.0, tar -xzf /tmp/app-3.3.0.tar.gz -C /opt/tramontana/releases/3.3.0, ln -s /opt/tramontana/releases/3.3.0 /opt/tramontana/app and echo "3.3.0" >> /opt/tramontana/HISTORY.

Solutions

Solution 1. The failure is case 4 from section 4: prepare_dest is used as an if condition, and that disables set -e throughout the function. The cp fails, prints its error, execution carries on, "destination prepared" is printed, the function returns 0 — the echo's code — and the if treats the result as good. The script announces that the backup is starting with the destination incomplete.

# Fix A: the function checks and exits explicitly.
prepare_dest() {
    mkdir -p /srv/tramontana/backups/new || return 1
    cp /etc/tramontana/does_not_exist.conf /srv/tramontana/backups/new/ || return 1
    echo "destination prepared"
}
# Fix B: do not use it as a condition; let set -e do its job.
prepare_dest; echo "starting the backup..."
operator@srv-tramontana:~$ bash /tmp/prep_a.sh; echo "code: $?"
cp: cannot stat '/etc/tramontana/does_not_exist.conf': No such file or directory
code: 1

Fix A is preferable when the function can fail in ways you want to tell apart; fix B, when any failure must abort. What will not do is the original, which promises to check and does not check.

Solution 2.

# with_cleanup [TEMPLATE] -> creates a temp dir, publishes it in TRAMONTANA_TMP
#   and installs the EXIT trap that deletes it. Returns: 0 if created; 73 if not.
with_cleanup() {
    TRAMONTANA_TMP=$(mktemp -d "${1:-/tmp/tramontana.XXXXXXXX}") || return 73
    # SINGLE quotes: the variable is read when the trap fires, not now.
    trap 'rm -rf -- "${TRAMONTANA_TMP:-}"' EXIT
    return 0
}
# Test: a child script that creates the temp dir and dies through set -e.
saved=$(bash -c '
    set -euo pipefail
    source '"$SCRIPT_DIR"'/lib/common.sh
    with_cleanup; printf "%s\n" "$TRAMONTANA_TMP"
    false                      # dies here; the EXIT trap must fire
' 2>/dev/null) || true
[[ -d $saved ]] && result="still exists" || result="deleted"
check "temp cleaned up after a failure" "deleted" "$result"
operator@srv-tramontana:~$ ~/scripts/test_common.sh | tail -2
  ok    temp cleaned up after a failure
0 failure(s)

The test matters because the function depends on two subtleties that are easy to break: the single quotes in the trap and the fact that EXIT fires even when what kills the script is set -e. A test pins that down; a comment does not.

Solution 3.

version="3.3.0"; dest="/opt/tramontana/releases/${version}"
mkdir -p "$dest"                         # -p does not fail if it already exists
# Check the package BEFORE extracting, as the course has required since 02-04.
tar -tzf "/tmp/app-${version}.tar.gz" >/dev/null || die 65 "corrupt package"
# We clean before extracting so that no remains are left from an earlier
# attempt with files that are no longer part of the release.
rm -rf -- "${dest:?}"/*
tar -xzf "/tmp/app-${version}.tar.gz" -C "$dest"
# ln -sfn: -f replaces the existing link, -n stops the new one being created
# INSIDE 'app' when it already exists as a link to a directory. And relative,
# as throughout the course: it is done from /opt/tramontana.
ln -sfn "releases/${version}" /opt/tramontana/app
grep -qxF "$version" /opt/tramontana/HISTORY 2>/dev/null \
    || printf '%s\n' "$version" >> /opt/tramontana/HISTORY   # only if missing

The four changes. mkdir -p stops the second run dying on the first line. The preceding tar -tzf detects a truncated package before anything has been touched. ln -sfn fixes the classic error: ln -s over a link that already points to a directory creates the new link inside that directory, leaving /opt/tramontana/releases/3.2.1/3.3.0, and the deployment changes nothing while you believe it has. And the grep -qxF — -x whole line, -F literal text — turns the >> into a conditional, avoiding a HISTORY with the same version repeated four times.

Conclusion

Your scripts no longer fail in silence, and for the first time you can interrupt them without fear.

  • You debug with bash -x and a PS4 that says file, line and function, narrowing regions with set -x/set +x, and you check the syntax without running anything using bash -n. You understand SC2086, SC2046, SC2164 and SC2155, and you know how to silence a warning by justifying it on the line above.
  • You know strict mode piece by piece — -e, -u, pipefail — and, above all, the five contexts where set -e does not act, with the case of a function used as a condition demonstrated and fixed in two ways. You use trap with EXIT, ERR and INT, capturing $? on the handler's first line, with single quotes, knowing that EXIT fires whatever happens except with SIGKILL.
  • You write an error_trace() that takes advantage of BASH_COMMAND, BASH_LINENO and FUNCNAME to give a real trace, and you design for idempotency: mkdir -p, ln -sfn, check before appending, write to .partial and rename at the end.
  • You retry with exponential backoff, you create temporary files with mktemp instead of predictable names using $$, and you lock with flock on a descriptor inside the script itself.
  • And backup_tramontana.sh finally exists: with locking, temporary files that clean themselves up, atomic writing, a checksum, 640 permissions on an archive containing db_password and documented exit codes.

One last question remains, the one that separates a script that works from one that gets deployed: what happens when cron runs this at 4:20 with nobody watching? Where does the log go? How is Marta told only when there is something to do? Where does the database password come from if it cannot be in the file? And what happens if one run takes longer than the interval between runs? In Production Scripts: Best Practices we close the module with that complete checklist, the canonical template with main "$@", the configuration precedence genuinely implemented, the principle of "silence if all is well", testing before production, and the integrating project: deploy.sh, with validation, a prior backup, an atomic link switch, a curl check and automatic rollback if 8080 does not respond.

Linux Course: From Beginner to System Administrator

Module 1: Introduction to Linux

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved