Your scripts already work. But "working" is what they do at midday, with your terminal open, your variables loaded and you watching the output. Production is something else: it is 4:20 in the morning, there is nobody there, the PATH is cron's meagre one, the disk is fuller than yesterday, the network is behaving oddly and the only evidence of what happened will be whatever your script wrote down. This lesson closes the module with the checklist that bridges that gap and with the project that proves it: deploy.sh, the Tramontana Bookings deployment script, with validation, a prior backup, an atomic switch and automatic rollback if the application does not respond. It is the module's final exam, and it uses absolutely everything you have learned.

Contents

  1. The production checklist
  2. Canonical structure and main "$@"
  3. Configuration: the precedence, implemented
  4. Secrets
  5. Logging: what, how and where
  6. Output for humans and for machines
  7. Locking, time limits and overlap
  8. Notifications: silence if all is well
  9. Testing, versioning and documentation
  10. When to leave Bash
  11. Project: deploy.sh

  1. The production checklist

Requirement Why
Absolute paths, not depending on the working directory Cron starts in $HOME with a minimal PATH
Strict mode and a cleanup trap Nobody is going to collect the temporary files for you
Idempotent and with --dry-run So you can relaunch it after a failure without thinking
Locking and a time limit Two at once, or one hung, do more damage than none
Logging with dates, decisions and duration It is the only evidence of what happened
Documented exit codes The caller must be able to act without reading the output
No secrets in the code or on the command line ps shows them to anybody
Silence if all is well An alert that goes off every day stops being read
shellcheck clean and tested in a test environment Nobody fixes a syntax error at 4:20
Versioned in git, with --help and a README In a year's time you will not remember why

None of them is optional in a script that runs on its own: the ten together are the difference between an automation and a time bomb.

  1. Canonical structure and main "$@"

The complete template, an evolution of the one from 04-01. Copy it as it is:

#!/usr/bin/env bash
#
# name.sh - What it does, in one line.
# Author : Name <email>   Date: 2026-08-18   Version: 1.0.0
# Usage  : name.sh [-h] [-v] [-q] [-n] <argument>
# Exit   : 0 ok | 2 incorrect usage | 69 service unavailable | 75 already running

set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"

# --- Constants ------------------------------------------------------------
readonly SCRIPT_VERSION="1.0.0"
readonly LOCKFILE="/var/lock/name.lock"
THRESHOLD=80              # configuration: 1. default value

# --- Functions ------------------------------------------------------------
usage() { ... };  validate() { ... };  work() { ... }

# --- Entry point ----------------------------------------------------------
main() {
    load_config           # 2. file
    parse_options "$@"    # 4. arguments (the environment, 3, goes with the constants)
    validate; work
}
main "$@"        # <- the LAST line of the file

main at the end lets the file be read from top to bottom like a piece of text: first the constants, then the pieces, and at the end the summary of what the program does. Since Bash needs functions to be defined before they are called, main "$@" has to be the last line, and putting it there also protects against a real failure: if the file gets truncated while being copied, Bash will not run a half script, because the call to main will have disappeared with the rest. And it goes with quotes, always, for what you know from 04-03: it is the only way for the arguments to arrive intact.

  1. Configuration: the precedence, implemented

The rule from 04-03 — arguments > environment > file > defaults — is implemented by applying it in that order, from the weakest to the strongest:

DISK_THRESHOLD=80                                  # 1. default
load_config() {                                    # 2. file
    local f="${CONFIG:-/etc/tramontana/deploy.conf}" key value
    [[ -r $f ]] || return 0
    while IFS='=' read -r key value; do
        key="${key// /}"
        [[ -z $key || $key == \#* ]] && continue
        case "$key" in
            disk_threshold) DISK_THRESHOLD="$value" ;;
            health_url)     HEALTH_URL="$value" ;;
            *) log "unknown key in $f: $key" ;;
        esac
    done < "$f"
}
DISK_THRESHOLD="${TRAMONTANA_DISK_THRESHOLD:-$DISK_THRESHOLD}"   # 3. environment
# 4. arguments: the getopts loop, which runs afterwards

${VAR:-$CURRENT} takes the value from the environment if it exists and, if not, keeps whatever was already there: that little trick is the whole mechanism. And log the unknown keys instead of ignoring them, because a misspelled disk_thresold that says nothing is a wasted night.

  1. Secrets

Three rules, in order of importance:

  • Never in the script. A file versioned in git with a password inside it is compromised for ever, even if you delete it: it stays in the history.
  • Never on the command line. mysql -p"$PASSWORD" is visible in ps aux to any user on the machine for as long as the command lasts. Yes in a file with 600 permissions, owned by the service's user, or in an environment variable set by the service manager.
read_secret() {                     # read_secret PATH -> prints the secret
    local f="$1" mode
    [[ -r $f ]] || die 78 "cannot read the secrets file: $f"
    mode=$(stat -c '%a' "$f")       # 600 or 640, never readable by others
    [[ $mode == 600 || $mode == 640 ]] || die 78 "insecure permissions ($mode)"
    < "$f" tr -d '\n'
}

A decent minimum: no passwords in the code and active verification of the permissions. Serious management — pass, a secrets manager, TLS certificates — is the subject of 06-05.

  1. Logging: what, how and where

A production script logs five things: that it has started (with its version and its parameters), every decision it takes, every change it makes to the system, that it has finished, and how long it took. With that you can reconstruct any incident.

readonly LOG="/var/log/tramontana/deploy.log"
record() {                          # record LEVEL MESSAGE...
    local level="$1" line; shift
    line="$(date '+%Y-%m-%dT%H:%M:%S%z') [$level] ${0##*/}[$$]: $*"
    printf '%s\n' "$line" >> "$LOG"
    (( TRAMONTANA_QUIET )) || printf '%s\n' "$line" >&2
}
# And this is how the log looks:
2026-08-18T14:02:11+0200 [INFO] deploy.sh[4471]: start v1.0.0, version=3.3.0
2026-08-18T14:02:19+0200 [WARN] deploy.sh[4471]: health KO, rolling back
2026-08-18T14:02:23+0200 [INFO] deploy.sh[4471]: end code=69 duration=12s

Three details of the format. The ISO 8601 date with a time zone (%Y-%m-%dT%H:%M:%S%z) sorts alphabetically the same as chronologically and is unambiguous across a clock change. The PID in brackets lets you separate two interleaved runs. And the message goes at the same time to the file and to stderr, so that it serves equally well in cron and by hand.

The duration is measured with SECONDS, the variable Bash increments on its own: SECONDS=0 at the start and $SECONDS at the end. And a note on the boundary: in Module 5 we will replace this file with the journal, with logger -t tramontana -p daemon.info as the bridge and journalctl to query it (05-06), plus logrotate so that the file does not grow for ever.

  1. Output for humans and for machines

A useful script serves two audiences: Marta wants to read, the monitoring wants to parse. The solution is not to choose, it is to offer both:

case "$FORMAT" in
    text) LC_ALL=C printf '%-10s %s\n' "Status:" "$status" ;;
    json) LC_ALL=C printf '{"status":"%s","version":"%s","duration":%d}\n' \
              "$status" "$version" "$SECONDS" ;;
esac

With --quiet everything informative is suppressed and only the errors are left — indispensable for cron, which emails any output — and with --json the output is a single parseable line. If your JSON starts to have nested structures, that is the signal from section 10: in Bash there is no decent way of escaping quotes inside arbitrary values.

  1. Locking, time limits and overlap

You already have the flock on descriptor 9 from 04-06. What is missing is the other half: what happens if one run takes longer than the interval between runs. The nightly backup is launched at 04:20; if one day it takes 25 hours, tomorrow's will find the lock. Three answers, in order of preference:

  • Abandon with a code of your own (75) and a warning. That is right for backups and reports: tomorrow's will do it.
  • Wait with flock -w 300 9, if the overlap is brief and waiting is acceptable. And never kill the previous one from the new one: if that is needed, the problem is the interval, not the lock.

The run also needs a ceiling, because a hung process does not fail: it just sits there, holding the lock for ever.

# A 20-minute limit; -k 30 sends SIGKILL if it has not died after 30 s.
timeout -k 30 20m tar -czf "$archive" -C "$TMP_DIR" . \
    || die 73 "the compression exceeded the time limit"

timeout returns 124 when it cuts things short on time, which lets you tell that apart from an ordinary failure. It is the direct application of the SIGTERM → wait → SIGKILL rule from 03-06.

  1. Notifications: silence if all is well

The principle is short and it is broken all the time: a script that reports when everything is fine teaches people to ignore its reports. If Marta gets a "backup correct" email every morning, by the third week she files it unread, and the day it says "backup FAILED" she will not read that one either. The working rule: notify only when there is something to do, and let the message say what has happened, what impact it has and what is expected of the person reading it.

notify() {                          # notify SUBJECT BODY
    (( NOTIFY )) || return 0
    printf '%s\n\nServer: %s\nLog: %s\n' "$2" "$(hostname)" "$LOG" \
        | mail -s "[Tramontana] $1" "$RECIPIENT"
}
wait_for_health || { notify "Deployment rolled back" \
    "Version ${version} did not respond on 8080; ${previous} was restored."; }

So that silence is not mistaken for "the script did not run", the counterpart is a success mark: the last line of the log carrying today's date, or a LAST_SUCCESS file with the timestamp. Checking "is the mark from today?" is trivial and catches 90% of the problems, including the worst: that the task has stopped running at all.

  1. Testing, versioning and documentation

Before a script touches production:

  1. shellcheck with no warnings and bash -n, both mandatory in CI: find scripts -name '*.sh' -exec shellcheck {} +.
  2. A complete --dry-run against the real system, reading the whole output: it is the last chance to see what is going to happen. And in a test environment with the same data and paths, with a snapshot of the VM before every destructive test.
  3. The failure path, not only the success path: remove permissions from the destination, point the health URL at a closed port, interrupt with Ctrl+C halfway. If you have only tested it when everything goes well, you have not tested it.
  4. With cron's environment, which is different from yours: env -i /home/operator/scripts/deploy.sh -n 3.3.0 /tmp/app.tgz reproduces the poverty of variables it will have at 4:20.

Versioning is not optional: ~/scripts is a git repository, with one commit per change and a message that says why. Deploying the script is then a git pull on the server — or better, copying it with rsync from a central repository, without leaving git credentials on the server. And the minimum documentation is three things: the header with usage and exit codes, a --help that always works, and a README.md in the folder saying which script does what and which one calls which. Nothing more; nothing less.

  1. When to leave Bash

Bash is excellent at orchestrating commands. It stops being so as soon as any of these signals appears:

Signal Where to go
More than ~300 lines, or a main that does not fit on one screen Python
Nested data structures, JSON beyond reading one field Python (json), or jq if it is only a query
Numeric computation, complicated dates, reports Python
Real concurrency with coordination between tasks Python, Go
Configuring machines, and more than one Ansible (07-06)
Dependencies between steps, state between runs An orchestration tool

The most honest signal: if you are writing eval, or escaping quotes inside quotes inside quotes, you have already crossed the line. For Tramontana the natural boundary is Ansible: the moment the deployment has to be done on three servers at once, deploy.sh becomes an Ansible role, and this module will have served its purpose, which was to teach you exactly what that role does underneath.

  1. Project: deploy.sh

The module's final exam. It validates, checks preconditions, backs up before touching anything, extracts, verifies, switches the link atomically, checks with curl and rolls back on its own if the application does not respond.

#!/usr/bin/env bash
#
# deploy.sh - Tramontana Bookings deployment with automatic rollback.
# Author : Systems operator <operator@srv-tramontana>  Date: 2026-08-18
# Usage  : deploy.sh [-h] [-v] [-q] [-n] [-t SEC] <version> <package.tar.gz>
# Exit   : 0 ok | 2 usage | 65 invalid package | 66 unreadable source | 73 cannot
#          write | 69 the app did not respond (rolled back) | 75 already running

set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
readonly SCRIPT_VERSION="1.0.0"
readonly BASE="/opt/tramontana"; readonly RELEASES="${BASE}/releases"
readonly LOCKFILE="/var/lock/tramontana-deploy.lock"
readonly LOG="/var/log/tramontana/deploy.log"
HEALTH_URL="${TRAMONTANA_HEALTH_URL:-http://10.0.2.15:8080/health}"
WAIT=60; DRY_RUN=0
version=""; package=""; previous=""; link_changed=0
# record() and usage() are the ones from section 5 and from 04-03, unchanged.
rollback() {                      # undoes the link switch if it was made
    (( link_changed )) || return 0
    record WARN "rolling back to release $previous"
    ( cd "$BASE" && ln -sfn "releases/${previous}" app )
    link_changed=0
}
cleanup() {
    local code=$?
    (( code != 0 )) && rollback
    record INFO "end code=${code} duration=${SECONDS}s"
    exit "$code"
}
trap cleanup EXIT
trap 'record ERROR "interrupted by signal"; exit 130' INT TERM
validate() {
    [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die 2 "invalid version: $version"
    [[ -r $package ]] || die 66 "cannot read the package: $package"
    # tar -tzf before extracting: the course's convention since 02-04.
    tar -tzf "$package" >/dev/null 2>&1 || die 65 "the package is not a valid tar.gz"
    [[ -w $RELEASES ]] || die 73 "cannot write to $RELEASES"
    require_command tar curl timeout || die 69 "missing dependencies"
}
wait_for_health() {               # 0 if it answers 200 within $WAIT seconds
    local deadline=$(( SECONDS + WAIT )) code
    while (( SECONDS < deadline )); do
        code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$HEALTH_URL" || true)
        [[ $code == 200 ]] && return 0
        sleep 3
    done
    return 1
}
main() {
    SECONDS=0
    while getopts ":hvqnt:" o; do
        case "$o" in
            h)  usage; exit 0 ;;          n) DRY_RUN=1 ;;   t) WAIT="$OPTARG" ;;
            v)  TRAMONTANA_VERBOSE=1 ;;   q) TRAMONTANA_VERBOSE=0 ;;
            \?) usage >&2; die 2 "unknown option: -$OPTARG" ;;
            :)  die 2 "option -$OPTARG needs a value" ;;
        esac
    done
    shift $(( OPTIND - 1 ))
    [[ $# -eq 2 ]] || { usage >&2; die 2 "<version> and <package> are required"; }
    version="$1"; package="$2"; validate
    exec 9>"$LOCKFILE" || die 73 "cannot open $LOCKFILE"
    flock -n 9 || die 75 "another deployment is already running"
    previous=$(basename "$(readlink -f "${BASE}/app")")
    record INFO "start v${SCRIPT_VERSION}, version=${version}, previous=${previous}"
    [[ $version == "$previous" ]] && { record INFO "already deployed; nothing to do"; exit 0; }

    if (( DRY_RUN )); then
        record INFO "DRY-RUN: would extract $package into ${RELEASES}/${version}"
        record INFO "DRY-RUN: app -> releases/${version}, health at $HEALTH_URL"
        exit 0
    fi
    "${SCRIPT_DIR}/backup_tramontana.sh" -q >/dev/null \
        || die 73 "the prior backup failed; aborting without touching anything"
    record INFO "prior backup completed"
    mkdir -p "${RELEASES}/${version}"          # idempotent
    rm -rf -- "${RELEASES:?}/${version:?}"/*
    timeout -k 30 10m tar -xzf "$package" -C "${RELEASES}/${version}"
    [[ -s "${RELEASES}/${version}/app.jar" ]] || die 65 "the release does not include app.jar"
    record INFO "release ${version} extracted ($(format_bytes \
        "$(du -sb "${RELEASES}/${version}" | cut -f1)"))"
    # Atomic switch: ln -sfn with a relative target, as Module 2 requires.
    ( cd "$BASE" && ln -sfn "releases/${version}" app )
    link_changed=1
    record INFO "link app -> releases/${version}"
    if wait_for_health; then
        link_changed=0                         # confirmed: no need to roll back
        record INFO "health OK at ${HEALTH_URL}"
        printf '%s\n' "$version"               # stdout: the active version
        exit 0
    fi
    record ERROR "version ${version} did not respond within ${WAIT}s"
    exit 69                                    # the EXIT trap will do the rollback
}
main "$@"
operator@srv-tramontana:~$ ~/scripts/deploy.sh 3.3.0 /tmp/app-3.3.0.tar.gz
2026-08-18T14:02:11+0200 [INFO] deploy.sh[4471]: start v1.0.0, version=3.3.0, previous=3.2.1
2026-08-18T14:02:14+0200 [INFO] deploy.sh[4471]: prior backup completed
2026-08-18T14:02:16+0200 [INFO] deploy.sh[4471]: release 3.3.0 extracted (99.2 MiB)
2026-08-18T14:02:16+0200 [INFO] deploy.sh[4471]: link app -> releases/3.3.0
2026-08-18T14:03:16+0200 [ERROR] deploy.sh[4471]: version 3.3.0 did not respond within 60s
2026-08-18T14:03:16+0200 [WARN] deploy.sh[4471]: rolling back to release 3.2.1
2026-08-18T14:03:16+0200 [INFO] deploy.sh[4471]: end code=69 duration=65s
operator@srv-tramontana:~$ readlink /opt/tramontana/app
releases/3.2.1

The service kept running. That is what this module has built: 3.3.0 did not start, the script detected it, rolled the link back in the same second, logged everything and returned a code (69) that says exactly what happened. And with -n you would have seen beforehand what it was going to attempt, without touching anything.

Go back over where each lesson lives: the shebang and the explicit exit are from 04-01; ${VAR:-}, the arrays and printf from 04-02; getopts, usage(), stdout versus stderr and the exit codes from 04-03; [[ =~ ]], case and the waiting while from 04-04; SCRIPT_DIR, the library and die() from 04-05; and set -euo pipefail, the traps, the locking, the idempotency and timeout from 04-06. Nothing here is new: it is everything that came before, together.

Common Mistakes and Tips

  • Relative paths. They work from your terminal and fail from cron: always absolute, or derived from SCRIPT_DIR. And do not assume the PATH, which in cron is /usr/bin:/bin; if you use something from /usr/local/bin, give the full path or an explicit PATH= at the top.
  • Putting main "$@" unquoted or in the middle of the file. The first breaks arguments with spaces; the second fails because the functions are not defined yet. And do not notify on success: it teaches people to ignore the notifications. Silence if all is well, and a checkable success mark.
  • Logging only the ending. Without the intermediate decisions you cannot reconstruct anything; log what you decided not to do as well. And always set a time limit: a hung process does not fail, it sits there holding the lock until somebody kills it.
  • Deploying without a prior --dry-run. Thirty seconds of reading against half an hour of restoring.
  • Tip: keep ~/scripts in git from today even if it is only you, because git log answers "since when has it done this?" better than your memory. And make every script print its version on the log's first line: when two servers behave differently, that will be the first thing you compare.
  • Tip: the best moment to write the folder's README is today, while you still remember why each script exists.

Exercises

Exercise 1. Add to deploy.sh a --json option which, on finishing, prints on stdout one line with version, previous, status (deployed or rolled-back), code and duration. It must also work when there is a rollback, without duplicating code.

Exercise 2. Write ~/scripts/check_tasks.sh, which verifies that the three nightly tasks (cron-backup.log, cron-purge.log and the deployment) have left a mark from today and notifies only if one is missing, applying the principle of silence if all is well.

Exercise 3. Luis wants to put this line in /etc/cron.d/tramontana: 20 4 * * * operator cd ~/scripts && ./deploy.sh $VERSION /tmp/app.tar.gz. Find the five problems and rewrite it.

Solutions

Solution 1. The key is not to duplicate: the single exit point already exists — the trap cleanup EXIT — and that is where the summary should be printed.

FORMAT="text"; status="unknown"
# In the options loop, which moves to manual because it is a long option:
#   --json) FORMAT="json"; shift ;;
summary() {                       # summary CODE
    (( $1 == 0 )) && status="deployed" || status="rolled-back"
    case "$FORMAT" in
        json) LC_ALL=C printf \
            '{"version":"%s","previous":"%s","status":"%s","code":%d,"duration":%d}\n' \
            "$version" "$previous" "$status" "$1" "$SECONDS" ;;
        *) printf '%s\n' "$version" ;;
    esac
}

# In cleanup(), just before the exit: summary "$code"
operator@srv-tramontana:~$ ~/scripts/deploy.sh --json 3.3.0 /tmp/app.tgz 2>/dev/null
{"version":"3.3.0","previous":"3.2.1","status":"rolled-back","code":69,"duration":65}

The 2>/dev/null leaves only the JSON line, because the log goes to stderr: the channel separation from 04-03 bearing fruit. And since the exit 0 on the success path also fires the EXIT, the printf '%s\n' "$version" is removed from the body of main so as not to print it twice.

Solution 2.

#!/usr/bin/env bash
# check_tasks.sh - Verifies that the nightly tasks left a mark from today.
# Author : Systems operator <operator@srv-tramontana>  Date: 2026-08-18
# Usage  : check_tasks.sh [-v]     Exit: 0 all up to date | 1 one is missing
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"   # shellcheck source=lib/common.sh
readonly RECIPIENT="[email protected]"
declare -A TASKS=( [backup]="/var/log/tramontana/cron-backup.log"
    [purge]="/var/log/tramontana/cron-purge.log"
    [deploy]="/var/log/tramontana/deploy.log" )
today=$(date '+%Y-%m-%d'); missing=()
for task in "${!TASKS[@]}"; do
    file="${TASKS[$task]}"
    # -s: it exists and is not empty. tail -1 avoids reading a log of thousands of lines.
    if [[ -s $file ]] && [[ $(tail -1 "$file") == "$today"* ]]; then
        log "$task: up to date"
    else missing+=("$task"); fi
done
(( ${#missing[@]} == 0 )) && exit 0     # silence if all is well
printf 'No mark for today (%s): %s\n' "$today" "${missing[*]}" >&2
printf 'Tasks %s have left no record for today on %s.\nCheck cron and the disk.\n' \
    "${missing[*]}" "$(hostname)" | mail -s "[Tramontana] Tasks not run" "$RECIPIENT"
exit 1

operator@srv-tramontana:~$ ~/scripts/check_tasks.sh; echo "code: $?"
code: 0
operator@srv-tramontana:~$ ~/scripts/check_tasks.sh -v; echo "code: $?"
[2026-08-18 08:05:01] backup: up to date
[2026-08-18 08:05:01] purge: up to date
No mark for today (2026-08-18): deploy
code: 1

Without -v and with everything correct the script prints nothing and returns 0: cron sends no email and Marta gets no noise. The email is only sent on the failure path, saying what is happening and what to check, as the course's convention requires.

Solution 3. The five problems:

  1. ~ is not expanded in a system crontab. The field is passed to sh -c with a minimal environment and HOME may not be defined: absolute paths.
  2. $VERSION is empty. Cron does not inherit your environment, so the script would receive one argument where it expected two and the validation would reject it. Just as well.
  3. The output is not redirected. Without >> log 2>&1, any message turns into an email and any error is lost if email is not configured.
  4. No SHELL or PATH declared at the top of the file, and a badly chosen time: 04:20 avoids the clock-change window and the overlap with other tasks.
  5. Deploying automatically from cron is a bad idea in the first place. A deployment is approved by Marta; what should run on its own is the backup.
# /etc/cron.d/tramontana
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
# Daily backup. 04:20 so as not to overlap with the log rotation.
20 4 * * * operator /home/operator/scripts/backup_tramontana.sh -q \
    >> /var/log/tramontana/cron-backup.log 2>&1
# Check that the nightly tasks left a mark. It only warns on failure.
5 8 * * * operator /home/operator/scripts/check_tasks.sh \
    >> /var/log/tramontana/cron-backup.log 2>&1

Absolute paths, an explicit PATH, MAILTO="" because the script already writes its own log and only notifies when necessary, and the deployment kept out of cron: it is launched by hand when Marta approves it. In Module 5 we will see that a systemd timer does all of this with better logging and better overlap control.

Conclusion

You have closed the module, and with it the gap between "it works for me" and "it runs on its own".

  • You have a ten-point production checklist and a canonical template with main "$@" as the last line, which makes the script read from top to bottom and stops it running half way if it is truncated. You implement the configuration precedence — arguments > environment > file > defaults — for real, logging even the unknown keys.
  • You keep secrets out of the code and off the command line, and you verify the permissions of the file that holds them.
  • You log start, decisions, changes, end and duration, with an ISO date and the PID, to a file and to stderr, knowing that in 05-06 that will move to the journal.
  • You produce output for humans and for machines, with --quiet and --json. You control overlap with flock, you set a ceiling with timeout knowing that 124 means "time ran out", and you apply silence if all is well with a checkable success mark as the counterpart.
  • You test with shellcheck, bash -n, --dry-run, a test environment, env -i and the failure path; you version in git and you document with a header, --help and a README.
  • You recognise when to leave Bash — 300 lines, nested JSON, concurrency, eval — and where to go. And deploy.sh exists: it validates, locks, backs up before touching anything, extracts with a time limit, switches the link atomically, checks the health and rolls back on its own when 3.3.0 does not start, leaving the service standing.

In /home/operator/scripts/ there is now a real toolbox: health_check.sh, bookings_report.sh, bookings_summary.sh, purge_releases.sh, backup_tramontana.sh, check_tasks.sh, deploy.sh, the lib/common.sh library and its test_common.sh, all with the same structure, the same exit codes and the same error discipline. That is no longer "knowing Bash": it is knowing how to automate.

But look at what has been surfacing between the lines and we have kept postponing. backup_tramontana.sh writes into /var/log/tramontana/ and nobody rotates that file. deploy.sh switches the link but does not restart the application, because you do not yet know how to manage services. The backup goes to /srv/tramontana/backups without our having discussed how much disk there is or what happens when it runs out. The tramontana group still has not been formally created, svc-tramontana exists by magic, and you have been using sudo without asking yourself who decides what you can do with it. In Module 5: System Administration all of that stops being magic: you will create real users and groups, configure sudo and the special permissions, install and pin package versions, manage disks and filesystems, turn your scripts into systemd services and timers with start-up, dependencies and automatic restart, send your logs to the journal with rotation, measure the server's performance, and finish with a backup and restore strategy worthy of the name. The scripts you have written will be the pieces; Module 5 is the system that holds them up. Update your VM snapshot and we will see you there.

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