Module 3 — Shell Scripting
Turn the Linux commands you know into automation: scripts, variables, I/O and exit codes, conditionals, loops, functions, and writing robust Bash.
- Lesson: Your First Bash Scripts
- Lesson: Streams, Redirection & Exit Codes
- Lesson: Conditionals & Tests
- Lesson: Loops, Arguments & Functions
- Lesson: Scripts You Can Trust
- Assignment 1: A system health-check script
- Assignment 2: A safe backup/parameterised script
Lesson: Your First Bash Scripts
What you'll learn
- What a Bash script actually is, and how the "shebang" line tells Linux how to run it.
- How to make a script executable with
chmod +xand the difference between./scriptandbash script. - How to store data in variables and use them safely with quoting.
- How to capture the output of a command into a variable with command substitution
$( ).
By the end you can turn a sequence of commands you already type by hand into a small, repeatable script you run with one command.
The lesson
A shell script is just a plain text file containing the same commands you would type into your terminal, saved so you can run them all at once. Instead of typing five commands every morning, you put them in a file and run that file. That is the whole idea of automation: do it once, save it, reuse it forever.
You will write and run every script in this module on the Jumpbox (10.100.100.254, Ubuntu, user ubuntu). Log in there first.
1. The shebang line
The very first line of a script should be a shebang (pronounced "shuh-bang"). It tells the operating system which program should interpret the file:
#!/usr/bin/env bash
The #! characters are the shebang marker. /usr/bin/env bash means "find bash on the system's PATH and run this file with it." This is more portable than hard-coding #!/bin/bash, because Bash lives in different places on different systems, but env is almost always at /usr/bin/env.
Create your first script with a text editor like nano:
nano hello.sh
Type this in:
#!/usr/bin/env bash
echo "Hello from the Jumpbox!"
Save and exit (Ctrl+O, Enter, Ctrl+X in nano).
2. Making it executable and running it
A fresh file is not executable yet. Check its permissions:
ls -l hello.sh
# -rw-rw-r-- 1 ubuntu ubuntu 50 Jun 1 10:00 hello.sh
There is no x (execute) bit. Add it with chmod ("change mode"):
chmod +x hello.sh
ls -l hello.sh
# -rwxrwxr-x 1 ubuntu ubuntu 50 Jun 1 10:00 hello.sh
Now there are three ways the script can run. Here is what happens in each case:
./hello.sh -> kernel reads the shebang, runs: bash hello.sh
(REQUIRES the execute bit)
bash hello.sh -> you explicitly hand the file to bash
(shebang ignored, execute bit NOT needed)
hello.sh -> "command not found" UNLESS the dir is on $PATH
Run it the normal way:
./hello.sh
# Hello from the Jumpbox!
Why the ./? The current directory is not on your PATH (the list of folders the shell searches for commands), for safety. So you tell the shell exactly where the file is: . means "here", and /hello.sh is the file in here.
bash hello.sh is handy while debugging because it does not need the execute bit and lets you add flags like bash -x hello.sh to trace execution.
3. Variables
A variable is a named box that holds a value. You assign with = and no spaces around it:
name="Ada"
count=5
name = "Ada" (with spaces) is an error — the shell would try to run a command called name. To read a variable back, put a $ in front:
echo "$name" # Ada
echo "$count" # 5
Variable names are case-sensitive and conventionally lowercase for your own variables, UPPERCASE for environment variables like HOME or PATH.
4. Quoting basics
Quoting is the single biggest source of bugs for beginners, so learn it now.
- Double quotes
"..."keep text together but still expand variables and command substitution. Almost always what you want around a variable. - Single quotes
'...'are literal: nothing inside is expanded. - No quotes lets the shell split the value on spaces and expand wildcards — usually a bug.
See the difference:
greeting="hello world"
echo "$greeting" # hello world (spaces preserved)
echo $greeting # hello world (collapsed to one space! word-split)
echo '$greeting' # $greeting (single quotes: literal)
The rule of thumb: always wrap your variable references in double quotes unless you have a specific reason not to. echo "$name", not echo $name.
5. Command substitution $( )
You can capture the output of a command and store it in a variable using $( ):
today=$(date +%F)
echo "Today is $today" # Today is 2026-06-01
files=$(ls | wc -l)
echo "There are $files items here"
$(date +%F) runs date +%F, grabs what it prints, and substitutes it in place. You may see an older backtick syntax `date` — it does the same thing but is harder to read and cannot be nested cleanly. Prefer $( ).
Putting it together, a slightly more useful script:
#!/usr/bin/env bash
user=$(whoami)
host=$(hostname)
now=$(date +"%F %T")
echo "Report by $user on $host at $now"
Save as report.sh, chmod +x report.sh, run ./report.sh. You just automated a little status line — the seed of every monitoring script you will write later.
6. Comments and habits
Anything after a # (except the shebang) is a comment the shell ignores. Use comments to explain why, not what:
# Use UTC so logs from all hosts line up
now=$(date -u +"%F %T")
Because you will commit these scripts to Git in a later module, build clean habits now: one shebang line, comments at the top saying what the script does, lowercase variable names, and quotes around variables.
Dig deeper
- GNU Bash Reference Manual
- Google Shell Style Guide
- Bash scripting tutorial (freeCodeCamp)
- Shebang explained (Linux Handbook)
- The Linux Documentation Project: Bash Guide for Beginners
Search terms
bash shebang usr bin env bash explainedchmod +x run shell script linuxbash variable assignment no spacesbash double vs single quotes differencebash command substitution dollar parentheseswhy ./ before script name bash
Check yourself
- What does the shebang line
#!/usr/bin/env bashdo, and why isenvused instead of a hard-coded path? - Why must you type
./hello.shinstead of justhello.sh, and when can you skip the./? - What is wrong with the line
name = "Ada", and how do you fix it? - What is the practical difference between
echo "$greeting"andecho $greeting? - Write a line that stores today's date into a variable called
todayand prints it.
Lesson: Streams, Redirection & Exit Codes
What you'll learn
- The three standard streams every program has: stdin, stdout, and stderr.
- How to redirect output to files with
>,>>,2>, and&>, and how to read input. - How to connect commands together with pipes
|. - How to read and use exit codes (
$?) to know whether a command succeeded. - How to read user input with
readand embed multi-line text with here-docs.
By the end you can control where a script's input comes from and where its output and errors go — the foundation of logging and error handling.
The lesson
Every command on Linux is wired up to three streams — think of them as three pipes attached to the program. You will use these constantly in scripts to capture results, separate errors, and chain tools together.
You will run all of this on the Jumpbox (10.100.100.254, user ubuntu).
1. The three standard streams
+---------------------+
stdin (0) -->| your command |--> stdout (1) normal output
| |--> stderr (2) error messages
+---------------------+
- stdin (standard input, file descriptor
0) — where a program reads input, by default your keyboard. - stdout (standard output, descriptor
1) — normal results, by default your screen. - stderr (standard error, descriptor
2) — error and diagnostic messages, also the screen by default.
Keeping normal output and errors on separate streams is deliberate: it lets you save results to a file while still seeing errors, or vice versa.
2. Redirecting output: >, >>, 2>, &>
A redirection changes where a stream goes. Send stdout to a file:
echo "line one" > out.txt # > OVERWRITES the file (creates if missing)
echo "line two" >> out.txt # >> APPENDS to the file
cat out.txt
# line one
# line two
> clobbers (replaces) the file every time — be careful. >> adds to the end.
Errors travel on stderr, so plain > does not capture them. Redirect stderr with 2>:
ls /does/not/exist 2> errors.txt # error goes to the file, not the screen
Common combinations:
command > out.txt 2> err.txt # stdout and stderr to SEPARATE files
command > all.txt 2>&1 # stderr follows stdout into the same file
command &> all.txt # shorthand for the same thing (Bash)
command > /dev/null 2>&1 # discard everything (/dev/null is a black hole)
The order in 2>&1 matters: it means "make descriptor 2 point wherever descriptor 1 currently points," so it must come after you redirect stdout.
3. Pipes: |
A pipe connects the stdout of one command to the stdin of the next, so you can build a processing chain:
ls -l /etc | grep conf | wc -l
This lists /etc, keeps only lines containing conf, then counts them. Data flows left to right:
ls -l /etc --stdout--> grep conf --stdout--> wc -l --> screen
Pipes only connect stdout, not stderr. Each small tool does one job well and you compose them — the Unix philosophy.
4. Exit codes and $?
When a command finishes it returns an exit code: a number from 0 to 255. By convention 0 means success and anything non-zero means failure. This is how a script knows whether the previous step worked.
The special variable $? holds the exit code of the last command:
ls /etc > /dev/null
echo "$?" # 0 (success)
ls /nope 2> /dev/null
echo "$?" # 2 (failure)
You will use exit codes in the next lesson to make decisions. Your own scripts should set their exit code too, with exit:
#!/usr/bin/env bash
if [[ -f /etc/hostname ]]; then
echo "found it"
exit 0 # success
else
echo "missing!" >&2 # send our error to stderr
exit 1 # failure
fi
Note >&2 — that redirects our echo to stderr, the correct stream for error messages, so callers can separate them from real output.
5. Reading input with read
read pulls a line from stdin into a variable. Combined with -p it prompts the user:
#!/usr/bin/env bash
read -rp "What is your name? " name
echo "Hello, $name"
-p "..."prints a prompt (no newline).-ris almost always correct: it stops backslashes from being treated specially. Makeread -ra habit.
You can read several values at once; read splits on whitespace:
read -rp "Enter first and last name: " first last
echo "First: $first Last: $last"
Because read reads from stdin, it also works with pipes and files:
while read -r line; do
echo "got: $line"
done < out.txt
That < out.txt is input redirection — it feeds the file into the loop's stdin instead of the keyboard.
6. Here-documents
A here-document (here-doc) feeds a block of multi-line text into a command's stdin without a separate file. You pick a marker word (commonly EOF):
cat <<EOF > config.txt
host = jumpbox
user = ubuntu
date = $(date +%F)
EOF
Everything between <<EOF and the closing EOF becomes input to cat, which here writes it to config.txt. Variables and $( ) are expanded inside, as shown. If you quote the marker — <<'EOF' — expansion is turned off and the text is taken literally, which is useful when writing scripts that contain $ signs.
grep "user" <<< "user=ubuntu"
7. Putting it together
#!/usr/bin/env bash
# Save a directory listing; log any error separately.
target="/etc"
if ls -l "$target" > listing.txt 2> errors.log; then
echo "Listing saved ($(wc -l < listing.txt) lines)"
else
echo "ls failed, see errors.log" >&2
exit 1
fi
This captures normal output to one file, errors to another, checks the exit code, and reports cleanly on the right streams — exactly the discipline real scripts need.
Dig deeper
- Bash Manual: Redirections
- TLDP — I/O Redirection (Advanced Bash-Scripting Guide)
- TLDP — Exit Codes With Special Meanings
- The
readbuiltin (Bash Manual) - Here-documents (TLDP Advanced Bash-Scripting Guide)
Search terms
linux stdin stdout stderr explainedbash redirect stderr to file 2>&1bash exit code $? meaningbash read command -r -p promptbash heredoc EOF tutoriallinux pipe command output to another command
Check yourself
- Name the three standard streams and their file descriptor numbers.
- What is the difference between
> fileand>> file? - What does
command > out.txt 2>&1do, and why must2>&1come last? - By convention, what exit code means success, and how do you read the last command's exit code?
- Write a here-doc that writes two lines to a file called
notes.txt.
Lesson: Conditionals & Tests
What you'll learn
- How to branch your script with
if,elif, andelse. - How to write tests with
[[ ]]for strings, numbers, and files. - How exit codes drive conditionals, and how to chain commands with
&&and||. - How to handle many possible values cleanly with
case.
By the end you can make scripts that react to their environment — checking whether a file exists, whether a service is up, or whether a value is what you expect.
The lesson
Real automation has to make decisions: if the disk is full, warn; if the service is down, restart it. In Bash, decisions are built on exit codes (from the last lesson) and the if statement.
Run everything on the Jumpbox (10.100.100.254, user ubuntu).
1. if is driven by exit codes
An if statement runs a command and branches on its exit code: 0 (success) is "true", non-zero is "false". This surprises newcomers — there is no boolean type, just exit codes.
if grep -q "ubuntu" /etc/passwd; then
echo "user ubuntu exists"
else
echo "no such user"
fi
grep -q prints nothing but exits 0 if it found a match. The structure is always:
if <command>; then
<runs when command succeeds / exit 0>
elif <other command>; then
<runs when that one succeeds>
else
<runs when none succeeded>
fi
Note fi (if backwards) closes the block, and the then needs a ; or a newline before it.
2. The test command [[ ]]
Most of the time you are not testing a real command but a condition — is this number bigger, does this file exist? Bash gives you [[ ... ]] for that. It is itself a command that exits 0 (true) or 1 (false):
count=5
if [[ $count -gt 3 ]]; then
echo "more than three"
fi
You may also see single-bracket [ ... ] (the older test command). Prefer [[ ]] in Bash: it is safer with empty variables, supports &&/|| inside, and does pattern matching. Always put spaces inside the brackets — [[$count is an error.
3. String tests
name="ubuntu"
[[ $name == "ubuntu" ]] # equal
[[ $name != "root" ]] # not equal
[[ -z $name ]] # true if string is EMPTY (zero length)
[[ -n $name ]] # true if string is NOT empty
[[ $name == ub* ]] # pattern match: starts with "ub"
Inside [[ ]], the right side of == is treated as a glob pattern unless quoted. So [[ $f == *.log ]] is true for any name ending in .log. Quote it ("*.log") to match literally.
A very common safety check:
read -rp "Enter a username: " u
if [[ -z $u ]]; then
echo "You must enter a name" >&2
exit 1
fi
4. Number tests
Numeric comparisons use letter operators, not symbols:
-eq equal -ne not equal
-gt greater than -lt less than
-ge greater or equal -le less or equal
disk_used=82
if [[ $disk_used -ge 90 ]]; then
echo "CRITICAL: disk over 90%"
elif [[ $disk_used -ge 75 ]]; then
echo "WARNING: disk getting full"
else
echo "Disk OK"
fi
Do not use > or < for numbers inside [[ ]] — there they mean string ordering, not numeric. Use -gt, -lt, etc. (For arithmetic you can also use (( )): if (( disk_used >= 90 )); then.)
5. File tests
These are the bread and butter of sysadmin scripts:
-e PATH exists (file or directory)
-f PATH exists AND is a regular file
-d PATH exists AND is a directory
-r PATH is readable -w writable -x executable
-s PATH exists AND is non-empty
config="/etc/hosts"
if [[ -f $config && -r $config ]]; then
echo "$config is a readable file"
else
echo "Cannot read $config" >&2
exit 1
fi
Notice && inside [[ ]] means "both conditions true". You can also use || for "either", and ! for "not": if [[ ! -d /backups ]]; then mkdir /backups; fi.
6. Chaining with && and || (outside brackets)
Between whole commands, && and || are shortcuts based on exit codes:
mkdir -p /tmp/work && echo "ready" # echo runs ONLY if mkdir succeeded
ping -c1 example.com || echo "host down" # echo runs ONLY if ping failed
A && BrunsBonly ifAsucceeded (exit 0).A || BrunsBonly ifAfailed (non-zero).
This is a concise alternative to a full if for one-liners. A handy pattern for "do this or bail out":
cd /var/log || exit 1
If cd fails, the script stops instead of running the rest in the wrong directory — a real bug-preventer.
7. case for many options
When you are comparing one value against many possibilities, a chain of elif gets ugly. case is cleaner:
read -rp "Action (start/stop/status): " action
case "$action" in
start)
echo "starting..." ;;
stop)
echo "stopping..." ;;
status)
echo "checking..." ;;
*)
echo "Unknown action: $action" >&2
exit 1 ;;
esac
Each branch is a pattern, then ), then commands, ending in ;;. The *) at the end is the catch-all (default), like else. Patterns are globs, so you can match *.log) or y|Y|yes) for several values at once. case closes with esac (case backwards).
8. Putting it together
#!/usr/bin/env bash
# Warn if a log directory is missing or its disk usage is high.
logdir="/var/log"
if [[ ! -d $logdir ]]; then
echo "No $logdir directory" >&2
exit 1
fi
used=$(df --output=pcent "$logdir" | tail -1 | tr -dc '0-9')
if [[ $used -ge 90 ]]; then
echo "CRITICAL: $logdir at ${used}%"
exit 2
else
echo "$logdir usage ${used}% — OK"
fi
This checks a file condition, computes a number from a command, and branches on it — the shape of the health-check you will build in the assignment.
Dig deeper
- Bash Manual: Conditional Constructs
- Bash Manual: Bash Conditional Expressions (test operators)
- if statements in Bash (Linux Handbook)
- Bash Manual — Conditional Expressions (
[[ ]]test operators) - TLDP — The case statement (Bash Beginners Guide)
Search terms
bash if statement exit code true falsebash double bracket test string comparisonbash numeric comparison -gt -lt -eqbash file test -f -d -e operatorsbash && || short circuit command chainingbash case statement esac example
Check yourself
- In an
if, what exit code counts as "true" and which counts as "false"? - Why is
[[ ]]preferred over single-bracket[ ]in Bash? - Which operator tests whether a string is empty, and which tests numeric "greater than"?
- What is the difference between
A && BandA || B? - In a
casestatement, what does the*)branch do, and what closes the whole block?
Lesson: Loops, Arguments & Functions
What you'll learn
- How to repeat work with
forandwhileloops. - How to read arguments passed to your script:
$1,$2,$#, and"$@". - How to write functions to organize and reuse code, and how they return values.
- How to use basic arrays to hold lists of items.
By the end you can write scripts that process many items, accept input from the command line, and stay organized as they grow.
The lesson
So far each script ran top to bottom once. Real automation repeats — over every file, every server, every line of a log — and takes input so the same script handles different jobs. That is loops, arguments, and functions.
Run everything on the Jumpbox (10.100.100.254, user ubuntu).
1. The for loop
A for loop walks through a list, binding each item to a variable in turn:
for name in alice bob carol; do
echo "Hello, $name"
done
The flow:
list: alice bob carol
| | |
v v v
name= name= name= <- body runs once per item
The list can come from a glob (file pattern), command substitution, or a number range:
for f in /etc/*.conf; do # every .conf file
echo "Found config: $f"
done
for n in $(seq 1 5); do # 1 2 3 4 5
echo "Attempt $n"
done
for i in {1..3}; do # brace expansion: 1 2 3
echo "Round $i"
done
Always quote "$f" when you use the loop variable — filenames can contain spaces. To process the lines of a file safely, prefer a while read loop (next).
2. The while loop
A while loop repeats as long as a command keeps succeeding (exit 0):
count=1
while [[ $count -le 3 ]]; do
echo "count is $count"
count=$((count + 1)) # arithmetic with $(( ))
done
$(( ... )) does integer math. The most reliable way to read a file or command output line by line is:
while read -r line; do
echo "log line: $line"
done < /var/log/syslog
This handles spaces and odd characters far better than a for loop over file contents. You can also pipe into it: df -h | while read -r line; do ...; done.
To stop early use break; to skip to the next iteration use continue:
for f in *.txt; do
[[ -s $f ]] || continue # skip empty files
echo "processing $f"
done
3. Script arguments
When you run ./deploy.sh web01 prod, the words after the script name become positional parameters:
./deploy.sh web01 prod
$0 $1 $2
$0— the script's own name.$1,$2, ... — the first, second, ... argument.$#— the count of arguments."$@"— all arguments, each kept as a separate quoted word.
#!/usr/bin/env bash
echo "Script: $0"
echo "First arg: $1"
echo "Number of args: $#"
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <hostname> [environment]" >&2
exit 1
fi
for arg in "$@"; do
echo "Got argument: $arg"
done
Use "$@" (with quotes), not $* or unquoted $@. Quoted "$@" preserves arguments that contain spaces; the others mangle them. Checking $# up front and printing a usage line is the mark of a polished script.
4. Functions
A function is a named block of commands you can call repeatedly. It keeps scripts readable and avoids copy-paste:
#!/usr/bin/env bash
log() {
echo "[$(date +%T)] $*"
}
check_dir() {
local dir="$1" # 'local' keeps the variable inside the function
if [[ -d $dir ]]; then
log "OK: $dir exists"
else
log "MISSING: $dir"
fi
}
check_dir /etc
check_dir /nope
Key points:
- Define a function before you call it.
- Inside a function, arguments are
$1,$2,"$@"— just like a script. The function above reads its argument as$1. - Use
localfor variables so they do not leak out and clobber others. This matters as scripts grow. $*and"$@"inside a function refer to the function's own arguments.
5. Return values from functions
Functions return an exit code (0–255), not a string, via return. That code feeds straight into if:
is_running() {
systemctl is-active --quiet "$1" # exits 0 if the service is active
}
if is_running ssh; then
echo "ssh is up"
else
echo "ssh is down"
fi
To get data (a string or number) out of a function, have it echo the value and capture it with command substitution:
disk_pct() {
df --output=pcent "$1" | tail -1 | tr -dc '0-9'
}
used=$(disk_pct /)
echo "Root filesystem is ${used}% full"
So: return for success/failure, echo + $( ) for actual values. Mixing these up is a classic beginner bug.
6. Basic arrays
An array holds an ordered list of values in one variable:
services=(ssh cron systemd-journald)
echo "${services[0]}" # ssh (first element, index 0)
echo "${services[@]}" # all elements
echo "${#services[@]}" # 3 (count of elements)
services+=(nginx) # append an element
Loop over an array with quoted "${services[@]}":
for svc in "${services[@]}"; do
if systemctl is-active --quiet "$svc"; then
echo "$svc: up"
else
echo "$svc: DOWN"
fi
done
Arrays are perfect for "a list of things to check" — services, hosts, directories — which is exactly what your health-check assignment needs.
7. Putting it together
#!/usr/bin/env bash
# Check several services passed as arguments; exit non-zero if any is down.
if [[ $# -eq 0 ]]; then
echo "Usage: $0 <service> [service...]" >&2
exit 1
fi
failed=0
for svc in "$@"; do
if systemctl is-active --quiet "$svc"; then
echo "$svc: up"
else
echo "$svc: DOWN" >&2
failed=$((failed + 1))
fi
done
echo "$failed service(s) down"
[[ $failed -eq 0 ]] # last command's exit code becomes the script's
This takes a variable list of arguments, loops over them, counts failures, and sets a meaningful exit code — clean, reusable, and ready for Git.
Dig deeper
- Bash Manual: Looping Constructs
- Bash Manual: Shell Functions
- Bash Manual: Arrays
- Positional parameters and
"$@"(Red Hat sysadmin blog) - Bash functions tutorial (Linux Handbook)
Search terms
bash for loop over files examplebash while read line file safelybash positional parameters $1 $@ $#bash quote "$@" vs $* differencebash function return value vs echobash array loop ${array[@]}
Check yourself
- When looping over the lines of a file, why is
while read -r linepreferred over aforloop? - What is the difference between
$#,$1, and"$@"? - Why should you write
"$@"with quotes instead of$@or$*? - How does a function "return" a number versus an actual string value?
- Given
services=(ssh cron nginx), how do you print the number of elements?
Lesson: Scripts You Can Trust
What you'll learn
- How
set -euo pipefailturns silent failures into loud, safe ones. - The quoting pitfalls that cause the worst real-world script bugs.
- How to lint your scripts automatically with ShellCheck.
- How to clean up reliably with
trap, and what "idempotency" means. - How to recognize when Bash is the wrong tool for the job.
By the end you can write scripts that fail safely, are checked by a tool, clean up after themselves, and that you would trust to run unattended.
The lesson
A script that works once on your machine is easy. A script you trust to run on a server at 3 a.m. — and not delete the wrong thing — is the real skill. This lesson is the difference between a beginner's script and a professional one.
Run everything on the Jumpbox (10.100.100.254, user ubuntu). Because you will commit these scripts to Git soon, robustness and readability are part of the deliverable.
1. The safety header: set -euo pipefail
By default, Bash keeps going after errors and treats undefined variables as empty — a recipe for disaster. Put this near the top of every serious script:
#!/usr/bin/env bash
set -euo pipefail
What each part does:
set -e exit immediately if any command fails (non-zero)
set -u error on use of an UNSET variable (catches typos)
set -o pipefail a pipeline fails if ANY command in it fails,
not just the last one
Why it matters — the classic horror story:
# WITHOUT set -u, if $dir is unset/typo'd this becomes: rm -rf /
rm -rf "$dir/"
# WITH set -u, the script stops on the unset variable instead.
set -e is helpful but has sharp edges: a command that is expected to fail (like grep finding nothing) would stop the script. Handle those explicitly:
if grep -q pattern file; then ...; fi # fine: the 'if' "uses" the exit code
count=$(grep -c pattern file || true) # 'or true' stops -e from firing
Add the header, then test that your script still does the right thing.
2. Quoting pitfalls (the #1 source of bugs)
You learned to quote variables in Lesson 1. Here is why it bites in practice:
file="my report.txt"
rm $file # WRONG: runs rm my report.txt -> deletes TWO files!
rm "$file" # RIGHT: deletes the one file "my report.txt"
Rules to internalize:
- Quote every variable expansion:
"$var","$1","${arr[@]}". - Quote command substitution when you assign or pass it:
x="$(cmd)". - Empty/unset variables vanish without quotes:
[ -f $f ]becomes[ -f ](broken) if$fis empty; inside[[ ]]this is safer, another reason to prefer[[ ]]. - Use
"${var}"braces when a variable is next to other text:"${name}_backup".
When in doubt, quote. There is rarely a downside.
3. ShellCheck — let a tool find the bugs
ShellCheck is a free static analyzer that reads your script and flags exactly these mistakes. Install and run it on the Jumpbox:
sudo apt-get install -y shellcheck
shellcheck myscript.sh
It produces messages like:
In myscript.sh line 7:
rm $file
^--^ SC2086: Double quote to prevent globbing and word splitting.
Each SCxxxx code links to a wiki page explaining the fix. Make "ShellCheck-clean" your standard before you commit a script to Git. You can even add a directive to silence a specific warning when you are sure: # shellcheck disable=SC2034.
4. Cleaning up with trap
A trap runs a command when your script exits or receives a signal (like Ctrl+C). Use it to remove temp files no matter how the script ends:
#!/usr/bin/env bash
set -euo pipefail
tmp="$(mktemp)" # safe unique temp file
trap 'rm -f "$tmp"' EXIT # ALWAYS runs on exit, success or failure
echo "working..." > "$tmp"
# ... use $tmp ...
# no manual cleanup needed; the trap handles it
EXIT fires on normal end, on error (thanks to set -e), and on interruption. Common signals to trap: EXIT (always), INT (Ctrl+C), TERM. Use mktemp to make temp files — never hardcode /tmp/myfile, which is predictable and can be hijacked.
5. Idempotency
Idempotent means running the script twice has the same result as running it once — no errors, no duplicates, no harm. This is essential for automation that may re-run.
mkdir /backups # FAILS the second time (already exists)
mkdir -p /backups # idempotent: fine whether or not it exists
# Instead of blindly appending (which duplicates on re-run):
grep -qxF "$line" file || echo "$line" >> file # add only if missing
Ask of every step: "what happens if this runs again?" Idempotent scripts are safe to retry, which is exactly what schedulers and config tools expect.
6. When Bash is the WRONG tool
Bash is excellent for gluing commands together, file shuffling, and short automation. It becomes painful — and you should reach for Python, Go, or a dedicated tool — when you have:
- Complex data structures (nested JSON, real math, dates arithmetic)
- Anything over ~100-200 lines or many functions
- Heavy string parsing -> use awk, or a real language
- JSON/YAML -> use jq / yq, not regex on text
- Needs unit tests, libraries, or to run on Windows too
A good instinct: if you are fighting Bash to do something a real programming language makes easy, switch. Parsing JSON with grep and sed is a well-known trap — pipe it through jq instead. Knowing the boundary is itself a senior skill.
7. A robust template to start from
#!/usr/bin/env bash
#
# describe.sh — what this script does, in one line.
# Usage: ./describe.sh <arg>
#
set -euo pipefail
# --- config ---
readonly LOGDIR="/var/log"
# --- cleanup ---
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
# --- functions ---
log() { echo "[$(date +%T)] $*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
# --- args ---
[[ $# -ge 1 ]] || die "Usage: $0 <arg>"
arg="$1"
# --- main ---
log "Starting with arg=$arg"
[[ -d "$LOGDIR" ]] || die "$LOGDIR not found"
log "Done"
This template has the safety header, a trap, helper functions (log, die), argument validation, and readonly constants. Run shellcheck describe.sh and fix anything it reports. Habits like these are exactly what your mentor will look for when you commit these scripts to Git.
Dig deeper
- ShellCheck (official site and wiki)
- Bash Manual: The Set Builtin
- Google Shell Style Guide
- Use
set -euo pipefailand traps (Red Hat sysadmin blog) jqmanual — for JSON instead of Bash
Search terms
bash set -euo pipefail explainedbash quoting word splitting SC2086shellcheck install ubuntu run scriptbash trap EXIT cleanup temp file mktempbash idempotent script mkdir -pwhen not to use bash use python instead
Check yourself
- What does each of
-e,-u, and-o pipefaildo inset -euo pipefail? - Show a one-line example where forgetting to quote a variable causes a real bug.
- What is ShellCheck, and at what point in your workflow should you run it?
- What does a
trap '...' EXITguarantee, and why usemktemp? - Give two situations where you should choose Python or another tool over Bash.
Assignment 1: A system health-check script
Goal: Write a Bash script that inspects the machine it runs on — disk usage, free memory, and whether a chosen service is running — and prints a clear, readable report with a meaningful exit code.
Where: On the Jumpbox (10.100.100.254, Ubuntu, user ubuntu). Create your script in your home directory, e.g. ~/healthcheck.sh.
Tasks
- Create
~/healthcheck.shstarting with the shebang#!/usr/bin/env bashand the safety headerset -euo pipefail. Add a top comment describing what the script does and its usage. - Add a
log()helper function that prints messages prefixed with a timestamp (usedate +%T). - Disk check: Get the percentage used of the root filesystem
/(hint:df --output=pcent / | tail -1 | tr -dc '0-9'). Store it in a variable. PrintDisk /: NN%. If usage is>= 90, label itCRITICAL; if>= 75, label itWARNING; otherwiseOK. Useif/elif/elsewith numeric tests. - Memory check: Report available memory. Use
free -mand extract the "available" column, or print the human-readable line withfree -h | grep Mem. PrintMemory available: NN MB(or the line). Optionally flagWARNINGif available memory is below a threshold you choose. - Service check: Accept a service name as the first argument
$1(default tosshif none is given). Usesystemctl is-active --quiet "$svc"inside anifto reportService <name>: uporService <name>: DOWN. - Exit code: Track whether anything was CRITICAL or the service was down. Exit
0if all healthy, non-zero (e.g.1) if any problem was found, so another script could rely on it. - Make the script executable (
chmod +x ~/healthcheck.sh) and run it both with no argument and with a service name, e.g../healthcheck.sh cron. - Run
shellcheck ~/healthcheck.shand fix every warning until it is clean.
Deliverable
The file ~/healthcheck.sh on the Jumpbox: executable, ShellCheck-clean, with a header comment, a log() function, the three checks, and a meaningful exit code. Paste a sample run (with no argument and with one argument) into your notes to show your mentor.
Acceptance criteria — you're done when:
- The script begins with
#!/usr/bin/env bashandset -euo pipefail. -
./healthcheck.shruns without errors and prints a disk line, a memory line, and a service line. - Disk usage is labelled
OK/WARNING/CRITICALusing numeric comparisons. - The service name comes from
$1and defaults sensibly when no argument is given. - The service status correctly shows
upfor a running service andDOWNfor a stopped/unknown one (test with a real and a fake name). - Running
echo $?after a healthy run prints0, and after a problem (e.g. a fake service) prints non-zero. -
shellcheck ~/healthcheck.shreports no warnings. - Every variable expansion is double-quoted.
Hints
- Default an argument:
svc="${1:-ssh}"means "use$1, orsshif$1is unset." - To track problems, start
problems=0and doproblems=$((problems + 1))whenever a check fails;exitwith0ifproblemsis0, else1. systemctl is-active --quiet nameprints nothing and just sets an exit code — perfect for anif.- Test the failure path with a name that does not exist, e.g.
./healthcheck.sh notaservice. - Re-read Lesson 3 (conditionals) for
if/elifand numeric tests, and Lesson 5 for the safety header and ShellCheck. - Keep functions small and quote everything — your future self reading this in Git will thank you.
Blocked for more than ~30 minutes after re-reading the lessons? Bring what you've tried to your mentor.
Assignment 2: A safe backup/parameterised script
Goal: Write a robust, parameterised Bash script that backs up a directory into a timestamped compressed archive, validates its inputs, is safe to re-run, and cleans up after itself.
Where: On the Jumpbox (10.100.100.254, Ubuntu, user ubuntu). Create ~/backup.sh and back up something harmless you own, such as ~/scripts or a test folder you create.
Tasks
- Create
~/backup.shwith the shebang#!/usr/bin/env bash, the safety headerset -euo pipefail, and a header comment showing usage:Usage: ./backup.sh <source-dir> [dest-dir]. - Arguments & validation: Read the source directory from
$1and an optional destination from$2(default the destination to~/backups). If no source is given, print the usage line to stderr andexit 1. If the source is not an existing directory (-d), print an error to stderr and exit non-zero. - Add a
die()helper that printsERROR: <message>to stderr and exits 1, and use it for your validation messages. - Idempotent destination: Create the destination directory with
mkdir -p "$dest"so re-running never errors. - Timestamped archive: Build an archive name that includes the source's basename and a timestamp, e.g.
scripts-2026-06-01_142530.tar.gz(hint:date +%F_%H%M%S). This guarantees each run makes a new file rather than overwriting an old backup. - Create the backup: Use
tar -czf "$dest/$archive" -C "$parent" "$base"to compress the source. Capture/print the resulting file size (e.g.du -h "$dest/$archive"). - trap cleanup: If you build the archive via a temporary file or staging area, register
trap 'rm -f "$tmp"' EXITso partial files are cleaned up if the script fails partway. - Report: Print a clear success line:
Backup complete: <path> (<size>). Exit0on success. - Make it executable, run it against a real folder, then run it a second time to prove it is idempotent (no errors, a new timestamped file appears).
- Run
shellcheck ~/backup.shand resolve all warnings.
Deliverable
The file ~/backup.sh on the Jumpbox: executable, ShellCheck-clean, parameterised, input-validated, idempotent, with a trap and a die() helper. Show your mentor the directory listing of ~/backups after running the script twice (two timestamped archives).
Acceptance criteria — you're done when:
- The script starts with
#!/usr/bin/env bashandset -euo pipefail. - Running it with no arguments prints a usage message to stderr and exits non-zero.
- Running it with a non-existent source directory prints an error to stderr and exits non-zero.
- A valid run creates
~/backupsif missing and writes a.tar.gzwhose name contains the source name and a timestamp. - Running the script twice in a row succeeds both times and produces two distinct archive files (proving idempotency).
- The archive actually contains the source files (verify with
tar -tzf <archive> | head). - A
trapis registered for cleanup, and adie()helper handles error exits. -
shellcheck ~/backup.shreports no warnings, and every variable is double-quoted.
Hints
- Default the destination:
dest="${2:-$HOME/backups}". - Split a path:
base="$(basename "$src")"andparent="$(dirname "$src")"; thentar -C "$parent" "$base"archives clean relative paths instead of absolute ones. - Make a safe temp file with
tmp="$(mktemp)"and remove it via the trap — never hardcode/tmp/backup. - Verify an archive without extracting:
tar -tzf "$dest/$archive" | head. - Re-read Lesson 4 (arguments and functions) and Lesson 5 (set -euo pipefail, trap, idempotency, ShellCheck).
- Test on a throwaway folder first (
mkdir -p ~/testdir && touch ~/testdir/a ~/testdir/b) so you never risk real data.
Blocked for more than ~30 minutes after re-reading the lessons? Bring what you've tried to your mentor.