Module 10 — Bash Scripting

Updated 2 September 2026

Module 10 — Bash Scripting

A script is a file of shell commands the machine runs for you — the step from typing commands to automating them. Everything you have learned since Module 1 becomes reusable, repeatable, and safe to run at 3 a.m. unattended. This module teaches scripts that behave under pressure: they fail loudly instead of silently, they handle bad input, and they clean up after themselves. Sloppy scripts are how outages happen; disciplined ones are the DevOps craft.

Legend used throughout: 🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)

Before you start

You need Modules Module 1 — What Linux Is–Module 9 — Package Management — this module composes the whole track. Load-bearing: exit codes and quoting (Module 5), chmod +x and ./ (Module 4), grep/sed/awk (Modules 6–7), and processes/signals (Module 8, for trap). Tools: an Ubuntu 24.04 machine; we install shellcheck (a script linter) in Part D. Sandbox: mkdir ~/scripting && cd ~/scripting.

This page uses Mermaid diagram blocks. Notion shows them as code by default — click the block and set it to Preview to see the diagram. This reminder appears once per page.

Part A — From commands to a script

A1. The shebang, and making a file executable

Official docs: bash(1) · Bash Reference Manual

🧠 A script is just a text file of commands. Two things make it runnable as a program. First, the shebang — the very first line, #!/usr/bin/env bash — which tells the kernel which interpreter to feed the file to when you execute it. (The #! is a literal magic marker the kernel looks for; env bash finds bash via PATH rather than hard-coding its location, which is why it's more portable than #!/bin/bash.) Second, the execute bit (Module 4): chmod +x script.sh, so you can run it as ./script.sh. Without the shebang, the kernel doesn't know it's a bash script; without +x, you can't run it by path (though bash script.sh always works — that names the interpreter explicitly, so it needs neither).

Counter-intuitive: the shebang is a comment to bash and an instruction to the kernel. That #! line starts with #, which bash treats as a comment and ignores — yet it is the most important line in the file, because the kernel reads it first (before bash is even involved) to decide which interpreter to launch. Two different readers, two different meanings, same line. And it only matters when you run ./script; running bash script ignores the shebang entirely, which is why a script with a wrong shebang can "work" one way and fail the other.
Real-world analogy — the language label on a document

A document sent for translation carries a cover note: "translate from French". The mailroom (kernel) reads that note to route it to the French translator; the translator themselves ignores the note and starts on the body. The shebang is that cover note — routing instruction for the dispatcher, invisible to the worker.

Where the analogy stops working. A mislabelled document reaches the wrong translator, who quickly notices the text isn't French and complains. A wrong shebang hands your bash script to sh (a stricter, smaller shell), which may run most of it fine and then fail cryptically on the one bash-only feature — a silent, partial success that is far more confusing than an outright rejection. The dispatcher's mistake surfaces deep inside the work, not at the door.

🧪 Exercise A1.1 — Your first real script
bash
cd ~/scripting
cat > hello.sh <<'EOF'
#!/usr/bin/env bash
echo "Hello from a script"
echo "I am running as: $(whoami)"
EOF
chmod +x hello.sh
./hello.sh          # run by path (needs shebang + execute bit)
bash hello.sh       # run by naming the interpreter (needs neither)
✅ Expected result — click to reveal
javascript
Hello from a script
I am running as: zaeem

(printed twice — once per way of running it; the username is yours — zaeem, ubuntu, root, whoever you are logged in as)

What to read out of it:

  • Both invocations produced identical output, but by different mechanisms: ./hello.sh made the kernel read the shebang and launch bash; bash hello.sh launched bash directly and handed it the file. Knowing both matters — the second is how you run a script you can't (or don't want to) chmod.
  • $(whoami) ran inside the script exactly as it does at the prompt (Module 5's command substitution) — a script is just your shell's grammar, saved to a file. Everything you know at the prompt works here.
  • The cat > file <<'EOF' … EOF you've been pasting since Module 6 is a heredoc: it feeds the lines up to EOF into the file. The quotes around 'EOF' stop the shell expanding $(whoami) while writing the file — you want that expansion to happen when the script runs, not when it's created. Unquoted EOF would have baked in your current username. A quoting subtlety with real consequences.

A2. Arguments — making a script reusable

🧠 A script that only does one fixed thing is barely better than a command. Positional parameters make it reusable: inside a script, $1 is the first argument you passed, $2 the second, and so on; $@ is all arguments, $# is how many, and $0 is the script's own name. So ./deploy.sh web-01 v2 gives the script $1=web-01, $2=v2. The same quoting law from Module 5 applies with full force: always "$1", always "$@" — unquoted, an argument containing spaces splits apart. ("$@" specifically expands to each argument as a separate quoted word — the one correct way to forward arguments onward.)

Real-world analogy — the fill-in-the-blank work order

A script with positional parameters is a work-order template: "Deploy version to host ". The blanks ($1, $2) are filled when the order is issued, so one template serves every deployment. Without blanks you'd need a separate form per host — which is exactly what hard-coded scripts are.

Where the analogy stops working. A human filling a form asks when a blank is left empty. A script does not: an unsupplied $1 expands to the empty string, silently, and the script marches on with a hole where the hostname should be (Module 5's form-letter trap, now inside your automation). Guarding against missing arguments is your job, not bash's — B-part conditionals and the ${1:?} guard exist precisely for this.

🧪 Exercise A2.1 — One script, many inputs
bash
cd ~/scripting
cat > args.sh <<'EOF'
#!/usr/bin/env bash
echo "Script name: $0"
echo "First argument: $1"
echo "All arguments: $@"
echo "Number of arguments: $#"
EOF
chmod +x args.sh
./args.sh alpha beta gamma
./args.sh              # run with NO arguments — watch the empty blanks
✅ Expected result — click to reveal
javascript
Script name: ./args.sh
First argument: alpha
All arguments: alpha beta gamma
Number of arguments: 3

then, with no arguments:

javascript
Script name: ./args.sh
First argument: 
All arguments: 
Number of arguments: 0

What to read out of it:

  • With arguments, $1/$@/$# reported exactly what you passed. With none, $1 and $@ came back empty — no error, no warning. That silent emptiness is the single most common source of script bugs, and $# (count zero) is how a well-written script detects it before doing damage.
  • A script that acts on $1 without checking $# first will, run with no argument, operate on the empty string — deleting "", deploying "", connecting to "". Part B's first job is to make that impossible.

Part A — Interview questions

🎯 "What does the shebang line do?" — asked verbatim in gitGood.dev's Top 50 Bash & Shell Scripting Interview Questions (June 2026); InterviewBit (2025) asks it as "What should be the first line of shell script?"

The shebang (#!/usr/bin/env bash or #!/bin/bash) is the script's first line; the kernel reads its #! marker when you execute the file by path and launches the named interpreter to run the script. #!/usr/bin/env bash locates bash via PATH (portable across systems where bash lives in different places); #!/bin/bash hard-codes the location (predictable, common on Linux). To bash itself the line is a comment.

The details that separate candidates: that it only takes effect when running ./script (not bash script, which names the interpreter directly); the env vs hard-path trade-off (portability vs predictability); and the failure mode — a #!/bin/sh shebang runs the script under dash on Debian/Ubuntu, breaking bash-only syntax (Module 1's dash lesson, now with teeth).

🎯 "How can you pass arguments to a shell script and access them?" — asked verbatim in LabEx's Shell Interview Questions (2025); Turing (2025) asks a variant, "How can you check the current working directory in a shell script and store it in a variable?"

Pass them after the script name (./deploy.sh web-01 v2); access them inside as positional parameters: $1, $2, … for individual arguments, $@ for all of them, $# for the count, $0 for the script name. Always double-quote them ("$1", "$@") to survive spaces. $@ inside double quotes uniquely expands to each argument as its own word — the correct idiom for forwarding arguments to another command. (Turing's variant: cwd="$(pwd)" — command substitution into a variable, Module 5.)

The details that separate candidates: "$@" vs "$*" (the former keeps arguments separate, the latter joins them into one string — the B-part interview question); shift to consume arguments in a loop; and validating $# before use, because unsupplied parameters expand to empty strings silently.

Part B — Making decisions and repeating work

B1. if, and the test brackets

🧠 if runs a branch based on a command's exit code (Module 5, finally paying off): if command; then … fi runs the then block when the command succeeds (exit 0). The command you'll most often test with is [[ … ]] — bash's condition test. File tests: [[ -f "$x" ]] (regular file exists), [[ -d "$x" ]] (directory), [[ -e "$x" ]] (exists at all). String tests: [[ -z "$x" ]] (empty), [[ -n "$x" ]] (non-empty), [[ "$a" == "$b" ]] (equal). Numbers use (( … )): (( n > 3 )). Chain branches with elif and else.

Trap — [[ ]] vs the old [ ], and the spaces. Two brackets ([[ ]]) is bash's modern test: safer with unquoted variables, supports &&/|| inside, no surprises. One bracket ([ ]) is the old POSIX test command — it is a command, so it needs spaces around everything ([ "$x" = "y" ], never ["$x"="y"]) and mishandles empty/unset variables in ways that cause real bugs. Prefer [[ ]] in bash scripts always; recognize [ ] in older scripts. And in both, the spaces around the brackets and operators are mandatory — bash parses them as words, and a missing space is a different (broken) command.
Real-world analogy — the sorting-office decision points

A parcel moves down a belt past sensors: is it over 2kg? → one chute; is the address abroad? → another; else → the default bin. Each sensor answers yes/no and routes accordingly. if/elif/else is that belt: each test a sensor, each branch a chute, and the parcel takes exactly one path.

Where the analogy stops working. A jammed sensor stops the belt visibly. A bash test that's subtly wrong — wrong bracket, missing quote around an empty variable — doesn't jam; it quietly returns the wrong yes/no and routes the parcel down the wrong chute, cheerfully. The belt keeps running; only the destinations are wrong. That silent misrouting is why the bracket and quoting discipline matters more than it looks.

🧪 Exercise B1.1 — Branch on what exists
bash
cd ~/scripting
cat > check.sh <<'EOF'
#!/usr/bin/env bash
target="$1"
if [[ -f "$target" ]]; then
  echo "$target is a regular file"
elif [[ -d "$target" ]]; then
  echo "$target is a directory"
else
  echo "$target does not exist"
fi
EOF
chmod +x check.sh
./check.sh /etc/hostname
./check.sh /etc
./check.sh /nope
✅ Expected result — click to reveal
javascript
/etc/hostname is a regular file
/etc is a directory
/nope does not exist

What to read out of it:

  • Three inputs, three branches — the script decided rather than blindly acting. Every one of those -f/-d/-e tests is Module 3's stat metadata, exposed as a yes/no.
  • The quoting of "$target" is not decoration: run ./check.sh with no argument and it still works (reports the empty string "does not exist") because the variable is quoted — unquoted, [[ -f $target ]] with an empty target becomes [[ -f ]], which tests whether the string "-f" is non-empty (always true), silently misrouting. The quotes are load-bearing.

B2. Loops — doing it N times

🧠 Three loop shapes cover almost everything. for over a list — for host in web-01 web-02 db-01; do … done — iterate named items (or a glob: for f in *.log). while read — while IFS= read -r line; do … done < file — process a file or stream line by line, the correct way to read files (the IFS= and -r prevent bash from mangling whitespace and backslashes). C-style for — for ((i=1; i<=5; i++)) — count. The loop variable is an ordinary variable; quote it ("$host", "$f") like any other.

Counter-intuitive: never loop over ls output — loop over a glob. for f in $(ls *.log) looks reasonable and breaks the moment a filename contains a space (it splits into pieces) or a newline (worse). for f in *.log — the bare glob — is correct: bash hands the loop each real filename as one item, spaces and all, and even the million-file case behaves. The instinct to "get a list with ls and loop over it" is imported from other languages and is wrong in bash for the same word-splitting reason quoting exists (Module 5). Glob directly; parse ls never.
Real-world analogy — the assembly line versus the mail sorter

for over a known list is an assembly line with a fixed batch of parts — each goes through the same station. while read is a mail sorter fed an endless belt of letters, handling each as it arrives without ever holding the whole pile. The shapes match the job: finite known set → for; open-ended stream → while read.

Where the analogy stops working. A human worker notices when two letters are stuck together and separates them. while read splits strictly on newlines and for on whitespace — feed them input glued the wrong way and they process the wrong units, silently. Choosing the right loop and the right separator (IFS) is how you make bash split the belt where you actually mean to.

🧪 Exercise B2.1 — The three loops
bash
cd ~/scripting
# for over an explicit list
for host in web-01 web-02 db-01; do echo "checking $host"; done
# for over a glob (create some files first)
touch a.log b.log c.txt
for f in *.log; do echo "found log: $f"; done
# while read, line by line
printf 'first\nsecond\nthird\n' > lines.txt
while IFS= read -r line; do echo "line: $line"; done < lines.txt
✅ Expected result — click to reveal
javascript
checking web-01
checking web-02
checking db-01
found log: a.log
found log: b.log
line: first
line: second
line: third

What to read out of it:

  • The glob loop found exactly the .log files and skipped c.txt — bash expanded *.log to real filenames before the loop ran (Module 5's globbing, in a loop). Note it did not run ls; the glob is the list.
  • while IFS= read -r processed each line intact. This exact idiom — over a file, or over a command's output via a pipe — is how you iterate log lines, config entries, or a list of hosts in a script. Memorize its shape; the IFS= … -r looks like noise but each piece prevents a specific real bug.

B3. Functions — naming a block of work

🧠 A function packages commands under a name: greet() { echo "Hi, $1"; }, called as greet World. Inside, $1/$2/$@ are the function's arguments (not the script's) — the same positional-parameter system, one level down. Declare variables local so they don't leak into the rest of the script. And functions "return" in two ways that trip everyone up: a function's exit status (0/non-zero, testable with if) is its true return value, set by the last command or an explicit return N; to return data (a string, a number), you echo it and capture with command substitution: result="$(myfunc)". Functions make scripts readable and testable — the same forces that make code maintainable anywhere.

Real-world analogy — the named procedure in a manual

A restaurant's manual doesn't re-describe "how to close the till" at every step that needs it — it names the procedure once and references it. Functions are named procedures: define "restart_service" once, call it wherever needed, fix it in one place. The manual stays readable because the procedures have names.

Where the analogy stops working. A human reading "return the total" knows you mean the number. A bash function has two channels — its exit status (success/failure) and its printed output (data) — and confusing them is the classic bug: writing return "$total" when total is 300 doesn't return 300 at all — return only takes 0–255 status codes, so 300 silently wraps to 44 (300 mod 256). Data comes back through echo + $(...), status through return. Two channels, never mix them.

🧪 Exercise B3.1 — Both return channels
bash
cd ~/scripting
cat > func.sh <<'EOF'
#!/usr/bin/env bash
# returns DATA via echo
timestamp() { echo "$(date +%H:%M:%S)"; }
# returns STATUS via exit code (testable)
is_even() { (( $1 % 2 == 0 )); }
now="$(timestamp)"           # capture the data
echo "It is now $now"
if is_even 4; then echo "4 is even"; fi     # test the status
if ! is_even 7; then echo "7 is odd"; fi
EOF
chmod +x func.sh
./func.sh
✅ Expected result — click to reveal
javascript
It is now 14:32:07
4 is even
7 is odd

(the time is your machine's own)

What to read out of it:

  • timestamp returned data — captured with $(...) into now. is_even returned status — tested with if. Two functions, two channels, cleanly separated. This is the pattern for every helper you'll write.
  • is_even has no echo and no return — its status is simply that of its last command, the (( )) test, which is exactly the yes/no you want. That's idiomatic bash: a predicate function is just a test whose success is the answer.

Part B — Interview questions

🎯 "What is the difference between [, [[, and (( ))?" — asked verbatim in gitGood.dev's Top 50 Bash & Shell Scripting Interview Questions (June 2026); LabEx (2025) asks "What is the difference between [[ and [ for conditional expressions?"

[ is the POSIX test command (a program/builtin): works everywhere, but needs careful quoting and mishandles empty/unset variables. [[ ]] is bash's keyword test: safer with variables, supports &&/|| and pattern matching (== with globs), no word-splitting surprises — the default for bash scripts. (( )) is arithmetic evaluation: for numeric comparisons ((( n > 3 ))), where you use >/</== on numbers and don't need $ on variables inside.

The details that separate candidates: choosing [[ ]] for strings/files and (( )) for arithmetic as a habit; knowing [ needs spaces around every token because it's literally a command receiving arguments; and the portability note — a script targeting POSIX sh (a #!/bin/sh shebang) cannot use [[ ]] and must fall back to [ ], which is why [ ] still matters.

🎯 "How do you write a function?" / "How do you return a value from a function?" — both asked verbatim in gitGood.dev's Top 50 Bash & Shell Scripting Interview Questions (June 2026); LabEx (2025): "How would you write a function in Bash and call it?"

Define with name() { commands; }, call as name arg1 arg2 (arguments become the function's $1, $2, $@). Two return channels: status via the exit code (last command's, or return N where N is 0–255) — test it with if name; then; data via echo/printf captured with result="$(name)". Use local var inside to avoid polluting the caller's variables.

The details that separate candidates: the status-vs-data distinction stated crisply (and the classic bug of return-ing a large number, which wraps mod 256); local for hygiene; and that a predicate function needs neither echo nor return — its last test's status is the answer, the idiomatic bash predicate.

🎯 "What is $@ vs $*?" — asked verbatim in gitGood.dev's Top 50 Bash & Shell Scripting Interview Questions (June 2026)

Both expand to all positional parameters; the difference appears when quoted. "$@" expands to each argument as a separate word — "$1" "$2" "$3" — preserving boundaries, so it's the correct way to forward arguments to another command. "$*" joins all arguments into a single word separated by the first character of IFS (a space by default) — useful when you genuinely want one string. Unquoted, $@ and $* behave identically (both word-split), which is why the quoted forms are what matter.

The details that separate candidates: "the difference only shows when quoted" — the whole point; a concrete use for each ("$@" to pass args through, "$*" to build a log message); and connecting it to the loop for arg in "$@" being the safe way to iterate arguments including those with spaces.

Part C — Scripts that don't betray you

C1. Strict mode — set -euo pipefail

🧠 By default, bash is dangerously forgiving: a command fails, and the script keeps going as if nothing happened — running the next step on the wreckage of the last. Three settings, put at the top of every serious script, change that:

  • set -e — exit on error: if any command fails (non-zero), stop the whole script immediately. No more ploughing on after a failed cd into a directory that doesn't exist.
  • set -u — error on unset variable: using $UNDEFINED aborts instead of expanding to empty (Module 5's form-letter catastrophe, now fatal instead of silent).
  • set -o pipefail — a pipeline fails if any stage fails, not just the last (Module 5's pipe-exit-code trap, fixed).

Together, set -euo pipefail is the standard first line after the shebang. It converts silent, compounding failure into a loud, immediate stop — which in automation is the difference between "job failed, alert fired, nothing damaged" and "job half-ran, corrupted state, discovered Monday".

Counter-intuitive: set -e has sharp edges, and knowing them is the interview. It does not trigger for a command whose failure you're already handling — if cmd; then, cmd || fallback, cmd && next all suppress it (that's correct — you're testing the failure, not ignoring it). But it also famously doesn't fire inside some constructs, and a function called in an if runs with -e partly disabled. The honest professional position: set -euo pipefail catches the overwhelming majority of "silent failure" bugs and is always worth having — and you still check critical commands explicitly, because -e is a safety net, not a guarantee. Candidates who claim set -e makes scripts bulletproof reveal they haven't been bitten yet.
Real-world analogy — the assembly line's emergency stop

Default bash is an assembly line with no stop button: a station jams, mangles the part, and the belt carries the wreckage to the next station, which dutifully works on garbage. set -e installs the emergency stop — first fault, whole line halts, a human looks. set -u is the sensor that halts when a required part is missing rather than fitting a hole with nothing.

Where the analogy stops working. A factory emergency-stop halts everything unconditionally. set -e has documented exceptions — it deliberately won't stop for failures you've wired a handler to, and has a few surprising blind spots — so it's an emergency stop that a safety engineer has to understand the wiring of. Trusting it blindly is how the one un-caught failure slips through; understanding its exceptions is the actual skill.

🧪 Exercise C1.1 — Watch strict mode stop the bleeding
bash
cd ~/scripting
# WITHOUT strict mode: failure is ignored
cat > loose.sh <<'EOF'
#!/usr/bin/env bash
echo "step 1"
cd /nonexistent-directory      # this FAILS
echo "step 2 (ran anyway — on the WRONG directory!)"
EOF
chmod +x loose.sh; ./loose.sh; echo "loose exit: $?"
echo "---"
# WITH strict mode: failure stops everything
cat > strict.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
echo "step 1"
cd /nonexistent-directory      # this FAILS
echo "step 2 (should NOT run)"
EOF
chmod +x strict.sh; ./strict.sh; echo "strict exit: $?"
✅ Expected result — click to reveal
javascript
step 1
./loose.sh: line 3: cd: /nonexistent-directory: No such file or directory
step 2 (ran anyway — on the WRONG directory!)
loose exit: 0
---
step 1
./strict.sh: line 4: cd: /nonexistent-directory: No such file or directory
strict exit: 1

What to read out of it:

  • The loose script's horror, in one line: cd failed, and step 2 ran anyway — but now in whatever directory the script started in, not the intended one. Imagine step 2 was rm -rf ./*. This is precisely how "the cleanup script deleted the wrong directory" postmortems begin (Module 5's Ticket 2, mechanism now named).
  • The loose script even exited 0 — reporting success while having done the wrong thing. Automation downstream trusts that 0.
  • The strict script stopped at the failure and exited 1 — loud, immediate, honest. Everything that would have run on the bad state simply didn't. set -euo pipefail earned its place at the top of the file in this one comparison.

C2. Cleanup with trap

🧠 A script that creates temporary files, lock files, or half-finished work must clean up — even when it fails partway, which is exactly when cleanup is hardest to remember. trap 'commands' EXIT registers cleanup that runs whenever the script exits, for any reason — success, error, or interruption (Module 8's signals). The idiom: create a temp dir with mktemp -d (a safe unique name), trap 'rm -rf "$tmpdir"' EXIT immediately, then work freely knowing the temp dir vanishes no matter how the script ends. You can also trap specific signals (trap cleanup INT TERM) to handle Ctrl+C gracefully.

Real-world analogy — the "leave no trace" campsite rule

A good camper sets up knowing they'll pack out everything — and crucially, the pack-out happens whether the trip ends as planned or is cut short by a storm. trap … EXIT is that commitment made automatic: register the pack-out at setup, and it executes on every possible ending, including the emergency one.

Where the analogy stops working. A camper can forget, or be unable, to pack out during a real emergency. trap … EXIT cannot forget — bash runs it on every exit path, including set -e aborts and most signals, which is exactly why it's reliable where a "cleanup at the end of the script" line is not (that line never runs if the script dies before reaching it). The trap is cleanup that survives the script's own death, which manual cleanup fundamentally cannot.

🧪 Exercise C2.1 — Temp files that clean themselves up
bash
cd ~/scripting
cat > tmpwork.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
tmpdir="$(mktemp -d)"                 # unique temp directory
trap 'rm -rf "$tmpdir"' EXIT          # register cleanup NOW, before any work
echo "working in $tmpdir"
echo "some data" > "$tmpdir/scratch.txt"
ls "$tmpdir"
echo "done — the temp dir will be gone after this exits"
EOF
chmod +x tmpwork.sh
./tmpwork.sh
ls -d /tmp/tmp.* 2>/dev/null | tail -1   # the script's temp dir is already gone
✅ Expected result — click to reveal
javascript
working in /tmp/tmp.aB3xK9mQ2r
scratch.txt
done — the temp dir will be gone after this exits

(the final ls does not show the script's temp dir — it was removed on exit)

What to read out of it:

  • The script created a temp dir, used it, and it was gone by the time the next command ran — the trap fired on exit. Crucially, this would have happened even if a command inside the script had failed and set -e aborted it: the trap runs on the error-exit too. Cleanup you can't forget.
  • mktemp -d gave a random unique name (/tmp/tmp.aB3xK9mQ2r) — never a hard-coded /tmp/mytemp, which two simultaneous runs would collide on. Unique temp names plus a cleanup trap is the standard safe pattern for any script that needs scratch space.

C3. Idempotence and safe changes

Official docs: bash(1)

🧠 A script is idempotent if running it twice leaves the same result as running it once — the property that makes automation safe to re-run after a failure. You've seen it in apt install (Module 9) and mkdir -p (Module 2, which doesn't error if the directory exists). Building it into your scripts: check before you act ([[ -f "$config" ]] || create_config), use idempotent commands (mkdir -p, apt install), and for edits, apply Module 5's safe-change pattern — write to a temp file and mv into place on success — so a failure mid-write never leaves a half-written config. The ${VAR:?message} guard (Module 5's Ticket 2, now yours) aborts loudly when a required variable is unset: dir="${1:?usage: script.sh DIR}".

Counter-intuitive: the goal isn't a script that succeeds — it's a script that's safe to run again after it failed. Real automation fails partway constantly — network blips, a full disk, a killed process. The question that separates a toy script from a production one is: what happens when I just run it again? An idempotent script picks up cleanly; a non-idempotent one double-appends the line, re-increments the counter, or fails because "the directory already exists". Designing for the re-run, not the happy path, is the mental shift from scripting to engineering.
Real-world analogy — the checklist that tolerates being restarted

A pilot's pre-flight checklist is designed so that if interrupted, you can start over from the top safely — "gear down" checked twice is fine, because the item confirms a state ("gear is down"), not an action ("lower the gear once more"). Idempotent scripts confirm states, not actions: "ensure the directory exists", not "create the directory".

Where the analogy stops working. A pilot notices if an action-based item was already done and skips it. A script has no such awareness unless you build it — mkdir dir run twice errors; only mkdir -p dir confirms-the-state. The tolerance for restart isn't automatic; it's a property you deliberately engineer into every step, choosing state-confirming commands over action-taking ones.

🧪 Exercise C3.1 — Run it twice, get the same result
bash
cd ~/scripting
cat > setup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
appdir="${1:?usage: setup.sh APPDIR}"       # abort loudly if no argument
mkdir -p "$appdir/logs"                       # idempotent: no error if it exists
config="$appdir/app.conf"
if [[ ! -f "$config" ]]; then                 # only create if absent
  echo "port=8080" > "$config"
  echo "created $config"
else
  echo "$config already exists — leaving it"
fi
EOF
chmod +x setup.sh
./setup.sh /tmp/myapp     # first run
./setup.sh /tmp/myapp     # second run — same result, no errors
./setup.sh                # no argument — watch the guard fire
✅ Expected result — last invocation fails on purpose — click to reveal
javascript
created /tmp/myapp/app.conf
/tmp/myapp/app.conf already exists — leaving it
./setup.sh: line 3: 1: usage: setup.sh APPDIR

What to read out of it:

  • First run created; second run noticed and left it — same end state, no error, no duplicate. That's idempotence: the second run confirmed the state rather than blindly re-acting.
  • The mkdir -p never complained the directory already existed (that's -p's whole job), and the [[ ! -f ]] guard protected the config from being overwritten on the re-run.
  • The no-argument run hit ${1:?...} and aborted with your usage message and a non-zero exit — the loud, immediate failure that a required-input guard is for. Compare Module 5's silent form-letter disaster; this is the cure, one line at the top.

Part C — Interview questions

🎯 "What does set -e do? When can it bite you?" — asked verbatim in gitGood.dev's Top 50 Bash & Shell Scripting Interview Questions (June 2026); LabEx (2025): "Describe the purpose of set -euxo pipefail at the beginning of a script."

set -e aborts the script the moment any command fails (non-zero) instead of continuing on a broken state. set -u aborts on use of an unset variable; set -o pipefail makes a pipeline fail if any stage fails (not just the last); set -x (the extra letter in LabEx's -euxo) traces each command as it runs — a debugging aid. Together set -euo pipefail is the standard strict-mode prologue.

The details that separate candidates: the "when it bites" honesty — set -e is suppressed for commands in if/&&/|| (correctly), doesn't fire in some subshell/function contexts, and can surprise with commands that "fail" normally (like grep finding nothing, Module 6's exit 1); the fix (grep … || true when a no-match is acceptable); and the mature framing that strict mode is a high-value safety net you combine with explicit checks, not a substitute for them.

🎯 "How do you check if a variable is empty?" / "What is parameter expansion ${VAR:-default}?" — both asked verbatim in gitGood.dev's Top 50 Bash & Shell Scripting Interview Questions (June 2026)

Empty check: [[ -z "$var" ]] (true if empty), [[ -n "$var" ]] (true if non-empty). Parameter expansion handles defaults and requirements: ${VAR:-default} uses default if VAR is unset/empty (without changing VAR); ${VAR:=default} also assigns it; ${VAR:?message} aborts with the message if VAR is unset/empty — the required-argument guard; ${VAR:+alt} uses alt only if VAR is set.

The details that separate candidates: reaching for ${1:?usage…} to validate required arguments instead of hand-writing an if-block; knowing :- vs := (one assigns, one doesn't); and pairing empty-checks with set -u so that unintended unset variables abort while intended defaults are handled explicitly — the two working together is the robust pattern.

Part D — Debugging, linting, and scheduling

D1. Debugging with set -x

🧠 When a script does the wrong thing, you need to see what it actually ran — after every expansion, substitution, and quote resolved. set -x (execution trace) prints each command, expanded, just before running it, each line prefixed with +. Turn it on for the whole script (add x to set -euxo pipefail) or wrap just the suspect section (set -x … set +x). It is the single most useful bash debugging tool, because it makes visible the exact difference between what you wrote and what bash did — which, after Module 5, you know is where nearly every shell bug lives.

Real-world analogy — the flight data recorder

set -x is the black box: it records every actual action the aircraft took, in order, so investigators see what the plane did, not what the pilot intended. When a script "should have worked", the trace is the recording that shows the one command that expanded to something you never meant.

Where the analogy stops working. A black box is read after the crash. set -x streams live and can be noisy — a big loop floods the screen — so the skill is scoping it: wrap the suspect section, not the whole flight. And unlike a recorder, it changes what you see without changing what runs, so it's safe to add and remove freely while hunting.

🧪 Exercise D1.1 — See what bash really did
bash
cd ~/scripting
cat > trace.sh <<'EOF'
#!/usr/bin/env bash
set -x                          # trace on
name="web-01"
greeting="deploying to $name"
echo "$greeting"
set +x                          # trace off
echo "done"
EOF
chmod +x trace.sh
./trace.sh
✅ Expected result — click to reveal
javascript
+ name=web-01
+ greeting='deploying to web-01'
+ echo 'deploying to web-01'
deploying to web-01
+ set +x
done

What to read out of it:

  • Each + line is what bash ran after expansion — you can see $name became web-01 inside greeting before echo ever ran (Module 5's expansion order, made visible). This is exactly the view you need when a variable "has the wrong value": the trace shows what it actually expanded to.
  • The plain lines without + are the program's real output (deploying to web-01, done); the + lines are the trace, which bash writes to stderr — so ./trace.sh 2>/dev/null would show only the real output, a handy way to separate the two.
  • After set +x, tracing stopped — echo "done" ran untraced. Scoping the trace to the suspect region keeps the signal readable.

D2. shellcheck — the linter that catches Module 5's bugs

🧠 shellcheck script.sh statically analyses a script and reports bugs before you run it — chiefly the exact quoting and expansion mistakes this whole track has warned about. It catches unquoted variables (Module 5's word-splitting), rm -rf $dir/ with an unquoted variable that could expand to disaster, [ ] misuse, useless-use-of-cat, and dozens more, each with a code (like SC2086) linking to an explanation. Install it (sudo apt install shellcheck) and run it on every script; better, wire it into your editor and your CI (Module 16-adjacent) so no unreviewed script ships. It is, unglamorously, the highest-leverage habit in this module.

Real-world analogy — the proofreader who knows the house style

shellcheck is a copy-editor who has read every style guide and remembers every comma rule you don't. It doesn't judge whether your article is good — it catches the mechanical errors (the unquoted variable, the missing space) that you're too close to see, before the piece goes to print.

Where the analogy stops working. A proofreader can miss things and gets tired. shellcheck is exhaustive and consistent on the mechanical checks it does make — but it also cannot know your intent: it flags patterns that are usually bugs, and occasionally you genuinely mean the "wrong" thing (with an inline # shellcheck disable=SC… to say so, deliberately). It catches the bugs of form; the bugs of logic are still yours.

🧪 Exercise D2.1 — Lint a script full of the bugs you now recognize
bash
cd ~/scripting
sudo apt install -y shellcheck    # install the linter (Module 9)
cat > buggy.sh <<'EOF'
#!/bin/bash
echo "checking $file"
rm -rf $dir/
EOF
shellcheck buggy.sh
✅ Expected result — click to reveal
javascript
In buggy.sh line 2:
echo "checking $file"
               ^---^ SC2154 (warning): file is referenced but not assigned (for output from commands, use "$(file ...)" ).

In buggy.sh line 3:
rm -rf $dir/
       ^---^ SC2115 (warning): Use "${var:?}" to ensure this never expands to / .
       ^--^ SC2154 (warning): dir is referenced but not assigned.
       ^--^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
rm -rf "$dir"/

What to read out of it:

  • Every finding is a bug this track named: SC2154 — a variable used but never set (both $file and $dir, Module 5's silent-empty-expansion); SC2115 — rm -rf $dir/ could expand to rm -rf / if $dir is empty (Module 5's Ticket 2 catastrophe, caught statically!); SC2086 — the missing double-quotes that cause word-splitting. shellcheck found in one second what took two modules to teach.
  • The Did you mean: rm -rf "$dir"/ is a concrete fix suggestion — and ${var:?} (Part C's guard) is exactly what SC2115 recommends to make the empty-expansion impossible. The linter is teaching the same lessons, enforced.
  • Running shellcheck on every script before it ships is not optional discipline for a professional; it is the cheapest bug-prevention available. Wire it into CI and the class of bug simply stops reaching production.

D3. Scheduling — cron

🧠 A script becomes automation when it runs on a schedule without you. cron is the classic scheduler: crontab -e edits your personal schedule, crontab -l lists it. Each line is five time fields plus a command: minute hour day-of-month month day-of-week command. 0 2 * * * means "02:00 every day"; */15 * * * * means "every 15 minutes"; 0 9 * * 1 means "09:00 every Monday". The classic trap you are already equipped for: cron runs with a minimal environment and a bare PATH (Module 5) — so scripts that "work in my terminal" fail under cron because their PATH additions and aliases don't exist. The fixes: absolute paths for everything, set any needed variables at the top of the script, and redirect output to a log (cron mails it nowhere useful otherwise). On modern systems, systemd timers (Module 11) are the more powerful alternative; cron remains ubiquitous and interview-relevant.

Trap — cron's silent failure is the default. A cron job that fails produces no visible error — cron tries to email output to a local mailbox nobody reads, so a broken nightly backup can fail every night for months undetected (until the day you need the backup). The disciplines that prevent it: redirect output to a log you monitor (>> /var/log/myjob.log 2>&1), make the script itself alert on failure, and — the professional standard — use a dead-man's-switch monitor that alerts when the job stops checking in, catching the failure the job itself can't report. "It's in cron" is not the same as "it's running".
Real-world analogy — the programmable timer on the office lights

cron is the timer switch on the building's lights: set it once — "on at 07:00, off at 19:00, weekdays" — and it acts forever without anyone present. The five fields are the dials on that timer.

Where the analogy stops working. A failed light timer is visible — you walk in to a dark office and know. A failed cron job is invisible by default: the lights that were supposed to turn on simply didn't, in a building nobody's watching, and you find out only when you needed them. Everything about operating cron well is compensating for that invisibility — logging, alerting, monitoring the absence of success.

🧪 Exercise D3.1 — Read and reason about a schedule (no live cron edit — reason it through)
bash
crontab -l 2>/dev/null || echo "no crontab yet for this user"
# Decode these WITHOUT running anything — say the schedule aloud:
#   30 3 * * *        /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
#   */10 * * * *      /usr/local/bin/healthcheck.sh
#   0 0 1 * *         /usr/local/bin/monthly-report.sh
✅ Expected result — click to reveal
javascript
no crontab yet for this user

(or your existing schedule, if any)

What to read out of it — decode each line:

  • 30 3 * * * — minute 30, hour 3, every day: 03:30 daily. Note the >> …log 2>&1 (Module 5) — this author redirects both streams to a log, escaping cron's silent-failure trap. Good hygiene, visible in the schedule.
  • */10 * * * * — every 10 minutes, all day. The */N step syntax is the one worth memorizing; it appears in every real crontab.
  • 0 0 1 * * — minute 0, hour 0, day-of-month 1: midnight on the 1st of every month. The two day fields (day-of-month and day-of-week) are a classic confusion — when both are set to specific values, cron runs when either matches, a genuinely surprising rule worth knowing before you write 0 0 13 * 5 expecting "Friday the 13th only".
  • The absolute paths (/usr/local/bin/…) are deliberate: cron's bare PATH wouldn't find these by name. Every command in a crontab should be an absolute path, for exactly the reason Module 5's Ticket 1 taught.

Part D — Interview questions

🎯 "What is the difference between cron and anacron?" — asked verbatim in InterviewBit's Linux Interview Questions (2025)

cron runs jobs at exact scheduled times and assumes the machine is always on — a job scheduled for 03:00 on a powered-off laptop simply never runs (the time passed while off). anacron is designed for machines that aren't always running (laptops, workstations): it works in days, not clock times, and runs a job if it's been more than N days since it last ran, catching up on missed jobs after boot. So cron for always-on servers with precise timing; anacron for periodic jobs that must eventually run on intermittent machines.

The details that separate candidates: the always-on assumption of cron as the key difference (and its consequence — missed jobs are simply skipped, not caught up); knowing many desktop distros run the daily/weekly /etc/cron.* jobs via anacron for exactly this reason; and that on modern systems systemd timers with Persistent=true cover both cases (precise scheduling and catch-up), which is where new automation increasingly lives (Module 11).

🎯 Corpus note — scheduling and debugging

The published banks touch cron lightly (InterviewBit's cron-vs-anacron, GeeksforGeeks mentioning crontab -e) and essentially skip script debugging — yet "your cron job isn't running, how do you debug it?" is a live-interview staple, and the answer is pure Module 5 plus this Part: check the job actually fired (cron's own log, /var/log/syslog or journalctl), check PATH and environment (cron's are minimal — absolute paths, set vars explicitly), check where output went (redirect to a log), and reproduce by running the script with cron's environment (env -i to strip it down). Being able to walk that debugging tree — and to write set -x and shellcheck into your workflow — is what the interview is really testing when it asks a scheduling question.

Part E — Toolkit

Official docs: Part E is reference material — every source it draws on is linked in the E3 documentation table below.

E1. Production practice — symptoms and fixes

SymptomWhat is really happeningWhat to runThe fix
Script "works" but did the wrong thing and reported successA command failed mid-script; without set -e the script continued and exited 0Add set -euo pipefail; re-runStrict mode at the top of every script
Script deleted / operated on the wrong pathAn unset or empty variable expanded to nothing (or /)shellcheck script.sh (catches SC2115/SC2086)set -u, ${VAR:?} guards, quote every expansion
Loop mangles filenames with spacesLooping over $(ls) or an unquoted expansion — word-splittingshellcheck flags it; inspect the loopLoop over a glob (for f in *.log), quote "$f"
Temp files pile up after failed runsCleanup at the end of the script never runs when it aborts earlyCheck for a leftover /tmp/… dirmktemp -d • trap 'rm -rf "$tmpdir"' EXIT right after creating it
"Works in my terminal, fails in cron"cron's minimal PATH and environment; no .bashrcLog the job's $PATH; reproduce with env -iAbsolute paths; set needed vars in the script; redirect output to a log
Nightly job "in cron" but its work never happenedSilent failure — cron mailed the error nowhere anyone readsCheck /var/log/syslog / journalctl for the cron runRedirect to a monitored log; add a dead-man's-switch alert
A variable "has the wrong value" and you can't see whyAn expansion resolved to something other than you expectedset -x around the suspect linesRead the + trace — it shows the post-expansion command bash actually ran

E2. Capstone — four tickets

Work these like real tickets: read the ticket, write the script (or the fix) and the explanation you would send, then open the worked answer. Everything needed was taught in this module.
🎫 Ticket 1 — "Harden this backup script — it ate a directory last week"

Ticket text: "Our backup script rm -rf $BACKUP_DIR/old cleared a production directory when BACKUP_DIR wasn't set. Rewrite it to be safe, and explain each guard."

Worked answer — the offending line plus its whole context needs the module's defenses, so present the rewritten prologue and reasoning: start with #!/usr/bin/env bash and set -euo pipefail (so an unset BACKUP_DIR aborts instead of expanding to empty — the direct cause); require the variable explicitly with BACKUP_DIR="${BACKUP_DIR:?BACKUP_DIR must be set}" (loud failure, not silent /); quote every use (rm -rf "$BACKUP_DIR/old" — SC2086); and before the destructive line, validate the target is what you expect ([[ -d "$BACKUP_DIR/old" ]] || { echo "nothing to clean"; exit 0; }). Run shellcheck on the result — it should come back clean, having flagged exactly the SC2115/SC2086 that caused the incident. The explanation to send: the incident was an unset variable meeting an unquoted rm -rf; set -u + ${VAR:?} + quoting makes that specific catastrophe impossible, and shellcheck in CI stops it reaching production again.

🎫 Ticket 2 — "Write a health-check script for our fleet of hosts"

Ticket text: "We need a script that takes a list of hostnames, checks each is reachable, and reports a summary — pass/fail count and which hosts failed. Must be safe to run repeatedly and from cron."

Worked answer — compose the whole module: strict-mode prologue; accept hosts as arguments ("$@") with a usage guard ([[ $# -gt 0 ]] || { echo "usage: $0 host..."; exit 1; }); a for host in "$@" loop (quoted, so spaces in an argument can't split it); inside, a check whose exit status is the pass/fail (if ping -c1 -W2 "$host" &>/dev/null; then …); accumulate counts in variables and failed hosts in an array; print a summary at the end; exit non-zero if any failed (exit "$fail_count"-style, so cron/CI can detect it). It's idempotent by nature (checking is read-only), and cron-safe if you use the absolute path to ping and redirect output. The design points to name: the loop's status-based check (Module 8's exit codes), the meaningful exit code for automation to consume, and the summary that makes the output actionable rather than a wall of per-host lines.

🎫 Ticket 3 — "This cron job silently stopped working three weeks ago"

Ticket text: "The log-rotation cron job 0 1 * * * rotate-logs.sh hasn't run successfully since the 3rd, but nobody noticed until /var filled up. Debug it and prevent recurrence."

Worked answer — walk the debugging tree from Part D. Did it fire? Check the cron log (grep CRON /var/log/syslog or journalctl -u cron) for the 01:00 runs — if they fired, it's the script failing; if not, it's the crontab. Why did it fail? The classic: rotate-logs.sh was written and tested in a terminal (full PATH) but the crontab entry uses a bare name rotate-logs.sh that cron's minimal PATH can't find — command not found, silently. Confirm by reproducing with cron's environment: env -i /bin/sh -c 'rotate-logs.sh'. Fix: absolute path in the crontab (/usr/local/bin/rotate-logs.sh), redirect output to a monitored log (>> /var/log/rotate.log 2>&1), and add strict mode to the script so a partial failure exits non-zero. Prevent recurrence: a dead-man's-switch monitor that alerts if the job stops reporting success — because the root failure here wasn't the bug, it was that nothing noticed for three weeks. The postmortem's lesson is monitoring the absence of success, not just adding a log.

🎫 Ticket 4 — "Turn this pile of one-off commands into a reusable deploy script"

Ticket text: "Deploys are currently ten commands an engineer pastes by hand, and they make mistakes. Make it a script anyone can run as deploy.sh <app> <version>, safely."

Worked answer — the synthesis ticket. Structure: strict-mode prologue; validate the two required arguments up front (app="${1:?usage: deploy.sh APP VERSION}"; version="${2:?usage: deploy.sh APP VERSION}"); wrap logical steps in named functions (fetch_release, stop_service, swap_symlink, start_service, verify) so the script reads like the runbook it replaces and each step is independently testable; use a trap to clean up (and ideally to roll back — repoint the symlink to the previous release, Module 3's deploy pattern) if any step fails; make each step idempotent where possible; check each critical command's success explicitly rather than trusting set -e alone for the destructive ones; and log what it's doing. Run shellcheck before it ships. The framing to deliver: this converts error-prone human pasting into a reviewed, tested, repeatable artifact with a rollback path — which is the entire reason scripting exists, and the on-ramp to configuration management (the Ansible track) that does this across a fleet.

E3. Documentation reference

TopicAuthoritative sourceVerified link
Bash language (shebang, tests, loops, functions, set, trap)bash(1); Bash Reference Manualbash(1) · Bash Reference Manual
Startup files (why scripts read no .bashrc)Bash Reference ManualBash Startup Files
Signals (for trap)signal(7)signal(7)
LintingShellCheckshellcheck.net
Schedulingcrontab(5), crontab(1)crontab(5) · crontab(1)

E4. Self-assessment

Answer out loud, without notes. The section number tells you where to re-read.

  1. What two things make a text file runnable as ./script.sh, and what does the shebang mean to the kernel versus to bash? (A1)
  2. $1, $@, $#, $0 — what is each, and what does an unsupplied $1 expand to? (A2)
  3. [ ] vs [[ ]] vs (( )) — when do you reach for each, and why does [ ] need spaces around everything? (B1)
  4. Why loop over a glob and never over $(ls)? What breaks, and when? (B2)
  5. A bash function's two return channels — name them, and give the classic bug of confusing them. (B3)
  6. Spell out set -euo pipefail and what each letter prevents. Name one case where set -e does not fire. (C1)
  7. Why trap 'cleanup' EXIT and not a cleanup line at the end of the script? (C2)
  8. What does "idempotent" mean, and why is "safe to re-run after failure" the real design goal? (C3)
  9. ${VAR:-default}, ${VAR:=default}, ${VAR:?msg} — what does each do differently? (C3)
  10. Which three findings would shellcheck report on rm -rf $dir/ with $dir unset, and how does each map to a bug this track taught? (D2)
  11. Decode */15 * * * * and 0 0 1 * *, and name cron's silent-failure trap and its cure. (D3)
  12. cron vs anacron — the key difference, and where systemd timers fit. (D3)

E5. Sources

Interview-question sources used in this module (fetched and quoted verbatim during research, September 2026):

gitGood.dev — Top 50 Bash & Shell Scripting Interview Questions (June 2026) · LabEx — Shell Interview Questions and Answers (2025) · InterviewBit — Linux Interview Questions (2025) · Turing — 100+ Linux Interview Questions (2025).

Corpus honesty note: the general Linux banks (GeeksforGeeks, WeCreateProblems, Mindmajix) barely touch scripting — so this module drew on dedicated shell-scripting question sets (gitGood.dev, LabEx), which are rich and current, plus the scripting-adjacent questions in the general banks (shell/BASH definitions, cron-vs-anacron). Scripting is where DevOps interviews go deep and practical — live "write a script that…" and "debug this script" rounds are standard — so the depth here matches the live format, not just the printed trivia.

All documentation links on this page were fetched and confirmed reachable on 2 September 2026.

Next: you can automate anything as a one-off. Now learn to make programs run forever, supervised — Module 11 — systemd and Services.
Spotted a mistake or want something added? Send me a note.