Module 10 — Bash Scripting
Updated 2 September 2026
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)
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.
Part A — From commands to a script
A1. The shebang, and making a file executable
🧠 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).
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
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
Hello from a script
I am running as: zaeem(printed twice — once per way of running it)
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.)
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
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
Script name: ./args.sh
First argument: alpha
All arguments: alpha beta gamma
Number of arguments: 3then, with no arguments:
Script name: ./args.sh
First argument:
All arguments:
Number of arguments: 0What 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.
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
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
/etc/hostname is a regular file
/etc is a directory
/nope does not existWhat 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.
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
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
checking web-01
checking web-02
checking db-01
found log: a.log
found log: b.log
line: first
line: second
line: thirdWhat 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.
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 250 fails, because return only takes 0–255 status codes, not arbitrary data. Data comes back through echo + $(...), status through return. Two channels, never mix them.
🧪 Exercise B3.1 — Both return channels
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
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.