Coming from Linux from Scratch? Module 14 of Linux from Scratch gave you a first script, arguments,
if,for, exit codes and a backup lab. This tutorial is the deep dive that follows it: the same ideas in full detail, then the habits that make scripts safe to run on real servers. If you have never opened a terminal, start with the Linux from Scratch course and come back after M14.
What you will be able to do#
By the end you will be able to:
- Write, run and debug scripts from the terminal, and explain why a script fails with
Permission denied,command not foundor$'\r'. - Use variables, quoting,
readandprintfcorrectly, including file names that contain spaces. - Make decisions with exit statuses,
[ ],[[ ]],(( ))andcase. - Loop over lists, ranges, files and command output, and read a file line by line without losing data.
- Organise code into functions with local variables, process arguments, and work with indexed and associative arrays.
- Trim, replace and change the case of strings with parameter expansion, and control every stream with redirection.
- Write production-grade scripts: strict mode and its gotchas,
trapcleanup,getoptsflags, input validation, timestamped logs,mktemp, idempotent steps, tracing withset -x, ShellCheck, and cron-safe wrappers. - Build a complete server-health report tool whose exit codes a monitoring system can act on.
Coming from M14? What is new here#
| Linux from Scratch M14 introduced | This tutorial adds |
|---|---|
A shebang line, chmod +x, running by path or with bash |
Why sh script.sh can fail on Bash features, Windows line endings, exit codes 126 and 127 |
Variables and $1, $#, "$@", ${1:-default} |
Quoting rules in depth, read -r, printf formats, "$@" vs "$*", shift, ${10} |
if with [[ ]] |
[ ] vs [[ ]] vs (( )), integer vs string comparison, regex with =~, file tests, case |
A for loop |
while, until, break, continue, safe line-by-line reading, $( ) and $(( )) pitfalls such as octal 08 |
Exit codes and $? |
Designing your own exit codes, return vs echo, local, PIPESTATUS |
set -euo pipefail as a habit |
What strict mode does not catch, and the patterns that fix each gap |
| A dated backup script | trap, getopts, validation, logging, mktemp, idempotency, debugging, ShellCheck, cron, log rotation, health checks and a full capstone tool |
One small difference you will notice: M14 used #!/usr/bin/env bash, which finds bash on your PATH. The scripts here use #!/bin/bash. Both are correct on Linux; the env form is handy on systems where Bash lives somewhere else, such as a Homebrew install on macOS.
Before you start#
What you need. A terminal with Bash 4 or newer: Linux, WSL on Windows, or macOS with a newer Bash installed (for example from Homebrew). Check with bash --version. macOS ships Bash 3.2, which cannot run associative arrays (declare -A), case conversion (${v^^}, ${v,,}), mapfile or printf '%(...)T'. You do not need internet access, a cloud account or admin rights.
How the examples were tested. All 162 scripts ran with Bash 5.2 in a fresh temporary folder, with LC_ALL=C, and pass ShellCheck 0.11.0 with no warnings. Where a line breaks a ShellCheck rule on purpose to show a bug, it carries a # shellcheck disable= comment with the reason.
How to read the output blocks.
- Output means you should see exactly this text.
- Sample output means the result depends on the date, your user name or your machine (timestamps, process IDs, temporary file names, live disk and load values). Yours will look slightly different.
- Typed answers are fed in with
printf '...' |, so you can reproduce the run exactly. When input is piped,read -pdoes not print its prompt, which is why prompts are missing from some outputs. - When a script ends with a non-zero exit status on purpose, the label says so. Check it yourself with
echo $?straight after the run.
Nine scripts exit with an error on purpose. They show how failures look and how your own scripts should report them:
| Script | Exit status | What it shows |
|---|---|---|
L3-M9-ex1-exit.sh |
2 | exit stops the script, so the lines after it never run |
L3-M9-ex2-exit.sh |
1 | Exiting early when the input is not a whole number |
L4-M1-ex1-set-u.sh |
1 | set -u stopping a script at a misspelled variable |
L4-M2-ex2-trap-on-error.sh |
1 | Cleanup still running when the script fails |
L4-M2-ex3-trap-int.sh |
130 | Handling Ctrl+C: the script sends INT to itself, then both traps run |
L4-M4-ex3-validate-args.sh |
2 | Rejecting an invalid IP address with a clear reason |
L4-M9-ex3-shellcheck-ci.sh |
1 | Linting a folder and failing, as a CI pipeline would |
L4-M11-ex3-health-check.sh |
1 | A health check with retries reporting an endpoint that is down |
L4-P1-solution.sh |
1 | Level 4, exercise 1: strict mode plus an EXIT trap |
The capstone tool also returns 1, 2 or 3 in some runs. Those are its designed monitoring codes (WARNING, CRITICAL, usage error), not failures.
Offline stand-ins for curl and crontab. So that every example runs anywhere without touching the network or your real schedule:
- The health check in L4-M11 uses a small
probefunction in place ofcurl. The comment above it shows thecurlline to use in production. - The cron examples in L4-M10 simulate scheduled runs inside a temporary folder instead of installing anything with
crontab. They print the crontab line you would add on a real server.
Download and practise. Every script, the capstone's sample metrics file and a README are in one zip. Files are named by level, module and example, for example L2-M7-ex2-csv.sh.
Download all 162 scripts (zip)
Keep the Bash cheat sheet open in another tab, and test yourself on all 25 questions here:
Learning path#
Work through the levels in order. Each one assumes the one before it.
| Level | Focus | Modules | You can then | Practice |
|---|---|---|---|---|
| 0: Zero | Shells, scripts, echo and printf, variables, quoting, read |
6 | Write and run small scripts that talk to the user | 5 exercises, 5 quiz questions |
| 1: Basics | Exit status, true/false, if/elif/else, [ ] and [[ ]], comparisons, file tests, case |
10 | Make a script choose what to do | 5 exercises, 5 quiz questions |
| 2: Loops | for, while, until, break, continue, reading files, $( ), $(( )) |
9 | Repeat work over lists, files and command output | 5 exercises, 5 quiz questions |
| 3: Intermediate | Functions, return vs echo, arguments, arrays, associative arrays, strings, here-docs, redirection, exit codes |
9 | Structure longer scripts and transform text | 5 exercises, 5 quiz questions |
| 4: Pro | Strict mode, trap, getopts, validation, logging, mktemp, idempotency, debugging, ShellCheck, cron, DevOps scripts |
11 | Write scripts you can trust in production | 5 exercises, 5 quiz questions |
| Capstone | Server-health report tool | 1 project | Combine every level in one tool with monitoring exit codes | 5 sample runs, extension ideas |
If you finished Linux from Scratch M14, skim Level 0 and the first half of Level 1, do their exercises to check yourself, and slow down from L1-M6 onward.
Contents#
Level 0: Zero
Level 1: Basics
Level 2: Loops
Capstone project: server-health report tool · Common mistakes · Interview questions · Where to go next · Further learning · FAQ
Level 0: Zero#
Start from nothing: shells, scripts, echo, variables, quotes and read
Welcome! This level assumes you have never written a script. By the end you will write, run and fix small scripts that talk to the user.
You only need a Linux or macOS terminal (or WSL / Git Bash on Windows). Every example runs on your own machine with no internet and no admin rights.
L0-M1: What a shell and a script are#
A shell is the program that reads the commands you type in a terminal and runs them. Bash (Bourne Again SHell) is the most common shell on Linux servers.
A script is just a text file full of shell commands. Instead of typing ten commands by hand every morning, you save them in a file and run the file. Same commands, typed once.
The first line of a script is usually the shebang: #!/bin/bash. The #! tells the operating system which program should read the file when you run it as ./file.sh.
Any other line starting with # is a comment. Bash ignores it. Comments are notes for humans: what the script does and why.
By convention script files end in .sh. Bash does not need the extension, but it helps people and editors recognise the file.
Key syntax
#!/bin/bash # shebang: always the very first line
# This is a comment # bash ignores everything after #
echo "Hello" # a command
Example 1 (Simple): Your first script#
A shebang, a comment and one command. That is a complete script. File: L0-M1-ex1-first-script.sh
#!/bin/bash
# My first script: prints a friendly greeting
echo "Hello from my first Bash script!"
Run it:
bash L0-M1-ex1-first-script.sh
Output:
Hello from my first Bash script!
Example 2 (Medium): A script with helpful comments#
Comments explain each step. $0 holds the name the script was started with. File: L0-M1-ex2-comments.sh
#!/bin/bash
# server-card.sh - prints a small info card for a server
# Author: you! Purpose: practise comments and echo
# Step 1: print a title
echo "=== Server Card ==="
# Step 2: print some details (fixed text for now)
echo "Name: web01"
echo "Role: web server"
# Step 3: show which file is running
echo "Printed by: $0"
Run it:
bash L0-M1-ex2-comments.sh
Output:
=== Server Card ===
Name: web01
Role: web server
Printed by: L0-M1-ex2-comments.sh
Example 3 (A bit harder): The shebang chooses the program#
We make two tiny files. One has #!/bin/cat as its shebang, so running it just prints the file. The other uses #!/bin/bash, so its commands run. File: L0-M1-ex3-shebang.sh
#!/bin/bash
# Work in a temporary folder so we do not leave files behind
work_dir=$(mktemp -d)
cd "$work_dir" || exit 1
printf '#!/bin/cat\necho "Hello"\n' > show.sh
printf '#!/bin/bash\necho "Hello"\n' > run.sh
chmod +x show.sh run.sh
echo "--- ./show.sh (shebang is /bin/cat) ---"
./show.sh
echo "--- ./run.sh (shebang is /bin/bash) ---"
./run.sh
cd / && rm -r "$work_dir"
Run it:
bash L0-M1-ex3-shebang.sh
Output:
--- ./show.sh (shebang is /bin/cat) ---
#!/bin/cat
echo "Hello"
--- ./run.sh (shebang is /bin/bash) ---
Hello
Common mistakes#
- Putting a space or blank line before
#!/bin/bash. The shebang only works on the very first line, starting at the first character. - Writing
#/bin/bash(missing!). That is just a comment, so the system picks a default shell instead. - Saving the script from Windows Notepad with CRLF line endings. You get errors like
$'\r': command not found. Fix withdos2unix file.shor save with LF endings. - Thinking comments slow the script down. They do not. Comment generously.
L0-M2: Running a script#
There are two everyday ways to run a script:
bash file.sh- you start bash and hand it the file. The file does not need any special permission../file.sh- you run the file directly. First give it execute permission once withchmod +x file.sh. The shebang then picks the interpreter.
Why ./? When you type a bare name like file.sh, the shell only searches the folders listed in the PATH variable, and your current folder is normally not one of them. ./ means "the file in this folder".
Two exit codes worth remembering: 126 means the file was found but is not executable, and 127 means the command was not found at all.
Key syntax
bash file.sh # run with bash, no permission needed
chmod +x file.sh # give execute permission (once)
./file.sh # run it directly from this folder
Example 1 (Simple): Run with bash#
The simplest way: bash followed by the file name. File: L0-M2-ex1-run-bash.sh
#!/bin/bash
echo "I ran!"
echo "My file name is: $0"
Run it:
bash L0-M2-ex1-run-bash.sh
Output:
I ran!
My file name is: L0-M2-ex1-run-bash.sh
Example 2 (Medium): Make it executable and run with ./#
chmod +x adds execute permission, then ./ runs the file from the current folder. File: L0-M2-ex2-run-dot-slash.sh
#!/bin/bash
echo "Running directly with ./ works because:"
echo " 1) chmod +x gave me execute permission"
echo " 2) my shebang says /bin/bash should read me"
Run it:
chmod +x L0-M2-ex2-run-dot-slash.sh && ./L0-M2-ex2-run-dot-slash.sh
Output:
Running directly with ./ works because:
1) chmod +x gave me execute permission
2) my shebang says /bin/bash should read me
Example 3 (A bit harder): What happens when it goes wrong#
We create a script without execute permission and try every way of running it. The exit codes 126 and 127 tell us what went wrong. File: L0-M2-ex3-run-errors.sh
#!/bin/bash
work_dir=$(mktemp -d)
cd "$work_dir" || exit 1
printf '#!/bin/bash\necho " tool says hi"\n' > tool.sh
echo "1) bash tool.sh (no permission needed):"
bash tool.sh
echo "2) ./tool.sh before chmod:"
./tool.sh 2>/dev/null
echo " exit status: $? (126 = found but not executable)"
echo "3) tool.sh without ./ :"
tool.sh 2>/dev/null
echo " exit status: $? (127 = command not found, . is not in PATH)"
chmod +x tool.sh
echo "4) ./tool.sh after chmod +x:"
./tool.sh
cd / && rm -r "$work_dir"
Run it:
bash L0-M2-ex3-run-errors.sh
Output:
1) bash tool.sh (no permission needed):
tool says hi
2) ./tool.sh before chmod:
exit status: 126 (126 = found but not executable)
3) tool.sh without ./ :
exit status: 127 (127 = command not found, . is not in PATH)
4) ./tool.sh after chmod +x:
tool says hi
Common mistakes#
- Typing
file.shinstead of./file.shand gettingcommand not found. - Running
chmod +xon the wrong file, or forgetting it, and gettingPermission denied. - Running
sh file.shwhen the script uses Bash features.shmay be a smaller shell (like dash) and fail on[[ ]]or arrays. Usebash file.sh. - Using
sudoto "fix" a permission error. You almost never need sudo to run your own script; you needchmod +x.
L0-M3: Printing with echo and printf#
echo prints text followed by a new line. It is the quickest way to show messages.
echo -n prints without the new line. echo -e turns on escapes like \t (tab) and \n (new line), but its behaviour differs between systems.
printf is the reliable, precise option. It takes a format string with placeholders: %s for text, %d for whole numbers, and widths like %-10s (left-aligned in 10 characters). It does not add a new line unless you write \n.
Key syntax
echo "text" # print + new line
echo -n "no newline" # stay on the same line
printf "%s has %d CPUs\n" web 4 # formatted output
printf "%-8s|%5s\n" name size # widths and alignment
Example 1 (Simple): Hello, echo#
Each echo prints one line. An empty echo prints a blank line. File: L0-M3-ex1-echo-basics.sh
#!/bin/bash
echo "Hello, class!"
echo
echo "Bash" "is" "fun"
echo "Today we learn echo."
Run it:
bash L0-M3-ex1-echo-basics.sh
Output:
Hello, class!
Bash is fun
Today we learn echo.
Example 2 (Medium): Same line, tabs and new lines#
-n keeps the cursor on the same line; printf handles tabs and new lines reliably. File: L0-M3-ex2-echo-n-printf.sh
#!/bin/bash
echo -n "Checking disk... "
echo "done"
printf "Name:\tweb01\n"
printf "Role:\tweb server\n"
printf "Line one\nLine two\n"
Run it:
bash L0-M3-ex2-echo-n-printf.sh
Output:
Checking disk... done
Name: web01
Role: web server
Line one
Line two
Example 3 (A bit harder): A neat table with printf#
Widths line up the columns. %-10s pads text to 10 characters on the left; %6d right-aligns a number in 6. File: L0-M3-ex3-printf-table.sh
#!/bin/bash
printf "%-10s %6s %8s\n" "SERVER" "CPU%" "STATUS"
printf "%-10s %6d %8s\n" "web01" 35 "OK"
printf "%-10s %6d %8s\n" "db01" 92 "HIGH"
printf "%-10s %6d %8s\n" "cache01" 7 "OK"
Run it:
bash L0-M3-ex3-printf-table.sh
Output:
SERVER CPU% STATUS
web01 35 OK
db01 92 HIGH
cache01 7 OK
Common mistakes#
- Forgetting
\nat the end of aprintfformat, so the next output runs onto the same line. - Expecting
echo "a\tb"to print a tab. Plain echo prints the backslash literally on most Linux systems. Useprintf. - Passing user data as the format:
printf "$msg". If the text contains%it breaks. Useprintf '%s\n' "$msg".
L0-M4: Variables#
A variable is a named box that holds a value. Create one with name=value and read it back with $name.
No spaces around =. name = value makes Bash try to run a command called name.
Use ${name} (with braces) when the variable is glued to other text, for example ${env}_backup.
Variable names use letters, digits and underscores and cannot start with a digit. By convention, ordinary script variables are lowercase and environment variables (settings passed down to programs, like PATH and HOME) are UPPERCASE.
export NAME=value makes a variable visible to programs your script starts (child processes). Without export, it stays inside your script.
Key syntax
name="web01" # assign (no spaces around =)
echo "$name" # read
echo "${name}_backup" # braces when text follows
export APP_ENV=prod # pass to child programs
readonly MAX=5 # cannot be changed later
Example 1 (Simple): Store and print#
Assign, print, then change the value. File: L0-M4-ex1-vars-basic.sh
#!/bin/bash
course="Bash Scripting"
students=24
echo "Course: $course"
echo "Students: $students"
students=25
echo "One more joined. Students: $students"
Run it:
bash L0-M4-ex1-vars-basic.sh
Output:
Course: Bash Scripting
Students: 24
One more joined. Students: 25
Example 2 (Medium): Build names from pieces#
Braces keep the variable name clear when text follows it. File: L0-M4-ex2-vars-braces.sh
#!/bin/bash
app="shop"
env="prod"
version="2.1"
host="${app}-${env}.example.com"
backup_file="${app}_${env}_v${version}.tar.gz"
echo "Host: $host"
echo "Backup file: $backup_file"
echo "Log folder: /var/log/${app}"
Run it:
bash L0-M4-ex2-vars-braces.sh
Output:
Host: shop-prod.example.com
Backup file: shop_prod_v2.1.tar.gz
Log folder: /var/log/shop
Example 3 (A bit harder): Exported vs not exported#
A child bash process only sees variables that were exported. File: L0-M4-ex3-vars-export.sh
#!/bin/bash
APP_ENV="prod"
SECRET_NOTE="not exported"
export APP_ENV
# We want the child bash to expand these, so single quotes are on purpose.
# shellcheck disable=SC2016
bash -c 'echo "Child sees APP_ENV=[$APP_ENV]"'
# shellcheck disable=SC2016
bash -c 'echo "Child sees SECRET_NOTE=[$SECRET_NOTE]"'
echo "Parent still sees SECRET_NOTE=[$SECRET_NOTE]"
Run it:
bash L0-M4-ex3-vars-export.sh
Output:
Child sees APP_ENV=[prod]
Child sees SECRET_NOTE=[]
Parent still sees SECRET_NOTE=[not exported]
Common mistakes#
- Spaces around
=:count = 5fails withcount: command not found. - Writing
$file_oldwhen you meant${file}_old. Bash looks for a variable calledfile_old, finds nothing and prints an empty string. - Using
$when assigning:$name=web01is wrong; writename=web01. - Overwriting important environment variables like
PATHorUSERby accident. Use lowercase names for your own variables.
L0-M5: Quoting: single vs double quotes#
Quotes control how Bash treats special characters.
- Double quotes
"..."keep spaces together but still expand$variablesand$(commands). Use them almost everywhere. - Single quotes
'...'keep everything exactly as typed. Nothing expands. - A backslash
\escapes a single character, for example\$or\".
Without quotes, Bash performs word splitting: a value with spaces is broken into separate words. This is the number one source of bugs with file names that contain spaces. Rule of thumb: always quote your variables: "$var".
Key syntax
echo "Hi $name" # double: expands -> Hi Asha
echo 'Hi $name' # single: literal -> Hi $name
echo "Price: \$5" # backslash escapes one character
touch "$file" # quote variables, always
Example 1 (Simple): Single vs double quotes#
Double quotes expand the variable; single quotes print it literally. File: L0-M5-ex1-quotes-basic.sh
#!/bin/bash
name="Asha"
echo "Hello $name"
# Single quotes on purpose: we want the literal text $name
# shellcheck disable=SC2016
echo 'Hello $name'
echo "Your home folder variable is written as \$HOME"
Run it:
bash L0-M5-ex1-quotes-basic.sh
Output:
Hello Asha
Hello $name
Your home folder variable is written as $HOME
Example 2 (Medium): Why quoting file names matters#
A file name with a space looks like one word to us but two words to unquoted Bash. We break it on purpose to see the problem. File: L0-M5-ex2-quotes-spaces.sh
#!/bin/bash
work_dir=$(mktemp -d)
file="$work_dir/my report.txt"
echo "quarterly numbers" > "$file"
name=$(basename "$file")
echo "Quoted \"\$name\" stays one piece:"
printf ' [%s]\n' "$name"
echo "Unquoted \$name is split at the space:"
# Deliberately unquoted to show word splitting
# shellcheck disable=SC2086
printf ' [%s]\n' $name
if [ -f "$file" ]; then
echo "With quotes, the file is found: $(cat "$file")"
fi
rm -r "$work_dir"
Run it:
bash L0-M5-ex2-quotes-spaces.sh
Output:
Quoted "$name" stays one piece:
[my report.txt]
Unquoted $name is split at the space:
[my]
[report.txt]
With quotes, the file is found: quarterly numbers
Example 3 (A bit harder): Quotes inside quotes#
Escape characters, mix quote styles, and use $'...' for special characters. File: L0-M5-ex3-quotes-nested.sh
#!/bin/bash
user="Ravi"
echo "He said \"deploy at 5\" to $user."
echo 'It'"'"'s easy to mix quote styles.'
echo "Cost: \$20 per month"
printf '%s\n' $'Tab here:\tdone'
echo "Commands still run inside double quotes: $(echo "$user" | tr "[:lower:]" "[:upper:]")"
Run it:
bash L0-M5-ex3-quotes-nested.sh
Output:
He said "deploy at 5" to Ravi.
It's easy to mix quote styles.
Cost: $20 per month
Tab here: done
Commands still run inside double quotes: RAVI
Common mistakes#
- Leaving variables unquoted:
rm $filewithfile="my report.txt"tries to deletemyandreport.txt. - Using single quotes when you wanted expansion:
echo 'Hello $name'prints$nameliterally. - Trying to put a single quote inside single quotes. You cannot; close the quote, add
"'"or\', and reopen. - Curly "smart quotes" pasted from Word or a chat app. Bash only understands straight quotes
'and".
L0-M6: Reading input with read#
read waits for the user to type a line and stores it in a variable.
-p "text"shows a prompt first.-rkeeps backslashes as typed. Always use it.- Give several variable names and
readsplits the line on spaces: the last variable gets whatever is left. -shides the typing (for passwords) and-t 10gives up after 10 seconds.
Note on the outputs below: we feed the answers in with printf ... | so the examples are repeatable. When you type the answers yourself you will also see the prompt text from -p. Bash only shows the prompt when a person is typing.
Key syntax
read -r -p "Name: " name # one value
read -r -p "Region count: " r c # two values from one line
read -r -s -p "Password: " pw # hidden input
read -r -t 10 answer || echo "timed out"
Example 1 (Simple): Ask for a server name#
Read one word typed by the user and print it back. File: L0-M6-ex1-read.sh
#!/bin/bash
read -r -p "Enter a server name: " server
echo "You will connect to: $server"
Run it:
printf 'web01\n' | bash L0-M6-ex1-read.sh
Output:
You will connect to: web01
Example 2 (Medium): Read two values on one line#
read can fill several variables at once from one line of input. File: L0-M6-ex2-read.sh
#!/bin/bash
read -r -p "Enter region and instance count: " region count
echo "Region: $region"
echo "Instances: $count"
Run it:
printf 'us-east-1 3\n' | bash L0-M6-ex2-read.sh
Output:
Region: us-east-1
Instances: 3
Example 3 (A bit harder): Ask until the answer is not empty#
Keep asking with read until the user types something. (The while loop is covered in Level 2.) File: L0-M6-ex3-read.sh
#!/bin/bash
bucket=""
while [[ -z "$bucket" ]]; do
read -r -p "Enter a bucket name: " bucket
if [[ -z "$bucket" ]]; then
echo "The name cannot be empty. Please try again."
fi
done
echo "Bucket name saved: $bucket"
Run it:
printf '\n\nmy-class-bucket\n' | bash L0-M6-ex3-read.sh
Output:
The name cannot be empty. Please try again.
The name cannot be empty. Please try again.
Bucket name saved: my-class-bucket
Common mistakes#
- Forgetting
-r: a typed path likeC:\newloses its backslashes. - Writing
read $name. Give the variable name without$:read name. - Not checking for empty input. Users press Enter by accident; validate (see Level 4).
- Expecting the
-pprompt to show when input is piped. It only shows for a person at a terminal.
Level 0 practice exercises#
Try each one yourself before opening the solution.
Exercise 1#
Write a script that stores your name and your favourite tool in two variables and prints: Hi, I am <name> and I like <tool>.
Hint: Two assignments, one echo with double quotes.
Show solution
File: L0-P1-solution.sh
#!/bin/bash
name="Priya"
tool="Bash"
echo "Hi, I am $name and I like $tool."
Run it:
bash L0-P1-solution.sh
Output:
Hi, I am Priya and I like Bash.
Exercise 2#
Ask the user which city they are in with read -p, then print Welcome to <city>!. Test it with: printf 'Pune\n' | bash solution.sh
Hint: read -r -p "..." city
Show solution
File: L0-P2-solution.sh
#!/bin/bash
read -r -p "Which city are you in? " city
echo "Welcome to $city!"
Run it:
printf 'Pune\n' | bash L0-P2-solution.sh
Output:
Welcome to Pune!
Exercise 3#
Use printf to print a 3-row table of courses with columns COURSE (left-aligned, 10 wide) and HOURS (right-aligned, 5 wide).
Hint: %-10s left-aligns text in 10 characters; %5d right-aligns a number in 5.
Show solution
File: L0-P3-solution.sh
#!/bin/bash
printf "%-10s %5s\n" "COURSE" "HOURS"
printf "%-10s %5d\n" "Linux" 20
printf "%-10s %5d\n" "Bash" 15
printf "%-10s %5d\n" "AWS" 40
Run it:
bash L0-P3-solution.sh
Output:
COURSE HOURS
Linux 20
Bash 15
AWS 40
Exercise 4#
Set topic="loops" and print it twice: once so the output shows Next topic: loops and once so it shows Next topic: $topic literally.
Hint: Double quotes expand; a backslash before $ stops expansion. Single quotes also work.
Show solution
File: L0-P4-solution.sh
#!/bin/bash
topic="loops"
echo "Next topic: $topic"
echo "Next topic: \$topic"
Run it:
bash L0-P4-solution.sh
Output:
Next topic: loops
Next topic: $topic
Exercise 5#
Read a first and last name from ONE line of input and print them as Last, First. Test with printf 'Ada Lovelace\n' | bash solution.sh
Hint: read can take two variable names.
Show solution
File: L0-P5-solution.sh
#!/bin/bash
read -r -p "First and last name: " first last
echo "$last, $first"
Run it:
printf 'Ada Lovelace\n' | bash L0-P5-solution.sh
Output:
Lovelace, Ada
Level 0: check yourself#
Five multiple-choice questions cover Level 0. Each answer comes with an explanation of why every option is right or wrong, and you earn XP as you go.
Level 1: Basics#
Make decisions: exit status, tests, comparisons, if and case
Scripts become useful when they can decide things: is the file there? Is the disk full? Did that command work?
Everything in this level is built on one idea: every command reports success or failure with an exit status, and if simply reacts to it.
L1-M1: Exit status and $?#
Every command finishes with a number called its exit status (or exit code). 0 means success; anything from 1 to 255 means some kind of failure.
The special variable $? holds the exit status of the last command. It changes after every command, even an echo, so save it straight away if you need it later: status=$?.
&& runs the next command only if the previous one succeeded; || runs it only if the previous one failed.
grep is a handy teacher here: exit 0 = found, 1 = not found, 2 = error (for example a missing file).
Key syntax
some_command
echo "$?" # 0 = success, non-zero = failure
status=$? # save it immediately
cmd && echo "worked" # run only on success
cmd || echo "failed" # run only on failure
Example 1 (Simple): Read the exit status#
grep -q searches quietly and reports only through its exit status. File: L1-M1-ex1-dollar-question.sh
#!/bin/bash
work_dir=$(mktemp -d)
echo "service started" > "$work_dir/app.log"
grep -q "started" "$work_dir/app.log"
echo "Search for 'started': exit status $?"
grep -q "crashed" "$work_dir/app.log"
echo "Search for 'crashed': exit status $?"
grep -q "started" "$work_dir/missing.log" 2>/dev/null
echo "Search in a missing file: exit status $?"
rm -r "$work_dir"
Run it:
bash L1-M1-ex1-dollar-question.sh
Output:
Search for 'started': exit status 0
Search for 'crashed': exit status 1
Search in a missing file: exit status 2
Example 2 (Medium): Act on success or failure#
&& and || let you react to the exit status in one line. File: L1-M1-ex2-and-or.sh
#!/bin/bash
work_dir=$(mktemp -d)
cd "$work_dir" || exit 1
mkdir reports && echo "Created reports/ (mkdir succeeded)"
mkdir reports 2>/dev/null || echo "reports/ already exists (mkdir failed, so || ran)"
touch notes.txt && echo "notes.txt ready" || echo "could not create notes.txt"
cd / && rm -r "$work_dir"
Run it:
bash L1-M1-ex2-and-or.sh
Output:
Created reports/ (mkdir succeeded)
reports/ already exists (mkdir failed, so || ran)
notes.txt ready
Example 3 (A bit harder): Save $? before it changes#
$? is overwritten by the very next command. Save it, and use PIPESTATUS to see every command in a pipe. File: L1-M1-ex3-save-status.sh
#!/bin/bash
false
echo "Right after false, \$? is: $?"
# Deliberate gotcha: this $? belongs to the echo above
# shellcheck disable=SC2320
echo "Now \$? is: $? (it belongs to the echo above)"
false
status=$?
echo "Saved status: $status"
echo "Still saved: $status"
false | true
echo "Pipe 'false | true': \$? = $?, PIPESTATUS = ${PIPESTATUS[*]}"
Run it:
bash L1-M1-ex3-save-status.sh
Output:
Right after false, $? is: 1
Now $? is: 0 (it belongs to the echo above)
Saved status: 1
Still saved: 1
Pipe 'false | true': $? = 0, PIPESTATUS = 1 0
Common mistakes#
- Checking
$?after anechoor another command; by then it reports that command, not the one you care about. - Thinking exit status 1 always means the same thing. Each program defines its own codes; read its manual (
man grep, section EXIT STATUS). - Writing
cmd && good || badand assuming it is an if-else. Ifgooditself fails,badruns too. Use a realifwhen it matters.
L1-M2: true and false#
true is a command that does nothing and always succeeds (exit 0). false does nothing and always fails (exit 1).
They are useful as on/off switches stored in variables, and for endless loops like while true that you stop with break.
Key syntax
true; echo $? # 0
false; echo $? # 1
if true; then ...; fi
while true; do ...; break; done
Example 1 (Simple): See the exit status#
$? shows 0 for true and 1 for false. File: L1-M2-ex1-true-false.sh
#!/bin/bash
true
echo "true returned: $?"
false
echo "false returned: $?"
Run it:
bash L1-M2-ex1-true-false.sh
Output:
true returned: 0
false returned: 1
Example 2 (Medium): Use a variable as a switch#
Store true or false and run it in an if. File: L1-M2-ex2-true-false.sh
#!/bin/bash
debug=true
if $debug; then
echo "Debug mode is ON."
fi
debug=false
if $debug; then
echo "This will not print."
else
echo "Debug mode is OFF."
fi
Run it:
bash L1-M2-ex2-true-false.sh
Output:
Debug mode is ON.
Debug mode is OFF.
Example 3 (A bit harder): Endless loop with a stop#
while true runs forever until break. (Loops are covered fully in Level 2.) File: L1-M2-ex3-true-false.sh
#!/bin/bash
count=0
while true; do
((count++))
echo "Health check $count"
if (( count == 3 )); then
echo "Stopping checks."
break
fi
done
Run it:
bash L1-M2-ex3-true-false.sh
Output:
Health check 1
Health check 2
Health check 3
Stopping checks.
Common mistakes#
- Writing
if [ false ]and expecting it to be false.[ false ]tests a non-empty string, which is TRUE. Useif falsewithout brackets. - Writing
while truewithout anybreakor exit condition: the script never ends. Press Ctrl+C to stop it.
L1-M3: The if statement#
if runs a command and checks its exit status. If the command succeeds (0), the code between then and fi runs.
The command is often a test such as [ ... ], [[ ... ]] or (( ... )), but it can be ANY command, for example if grep -q ERROR app.log.
(( ... )) is for arithmetic comparisons with normal symbols: >, <, >=, ==. fi is just if spelled backwards and closes the block.
Key syntax
if CONDITION; then
commands
fi
if (( cpu > 80 )); then echo "busy"; fi
if grep -q ERROR app.log; then echo "errors found"; fi
Example 1 (Simple): Warn when disk usage is high#
The echo runs only because the condition is true. File: L1-M3-ex1-if.sh
#!/bin/bash
disk_used=85
if (( disk_used > 80 )); then
echo "Warning: disk usage is ${disk_used}%."
fi
Run it:
bash L1-M3-ex1-if.sh
Output:
Warning: disk usage is 85%.
Example 2 (Medium): Check a number typed by the user#
Use read with if to react only to a large CPU value. File: L1-M3-ex2-if.sh
#!/bin/bash
read -r -p "Enter CPU usage (0-100): " cpu
if (( cpu >= 90 )); then
echo "CPU is very busy at ${cpu}%."
fi
echo "Check finished."
Run it:
printf '95\n' | bash L1-M3-ex2-if.sh
Output:
CPU is very busy at 95%.
Check finished.
Example 3 (A bit harder): Create a folder only if it is missing#
if with ! runs the block only when the folder does not exist yet. ([ -d ] is explained in the file tests module.) File: L1-M3-ex3-if.sh
#!/bin/bash
work_dir=$(mktemp -d)
logs="$work_dir/logs"
if [ ! -d "$logs" ]; then
mkdir "$logs"
echo "Created the logs folder."
fi
if [ -d "$logs" ]; then
echo "The logs folder is ready."
fi
rm -r "$work_dir"
Run it:
bash L1-M3-ex3-if.sh
Output:
Created the logs folder.
The logs folder is ready.
Common mistakes#
- Forgetting
thenorfi: you getsyntax error near unexpected token. - Missing the semicolon:
if (( x > 5 )) thenmust beif (( x > 5 )); then(or putthenon the next line). - Using
>inside[ ]:[ 10 > 9 ]creates a file called9! Use-gtin[ ]or>in(( )).
L1-M4: if-else#
else gives a second path: one block runs when the condition is true, the other when it is false. Exactly one of them runs.
Key syntax
if CONDITION; then
commands_when_true
else
commands_when_false
fi
Example 1 (Simple): Even or odd#
Pick one of two messages based on the remainder (%). File: L1-M4-ex1-if-else.sh
#!/bin/bash
number=7
if (( number % 2 == 0 )); then
echo "$number is even."
else
echo "$number is odd."
fi
Run it:
bash L1-M4-ex1-if-else.sh
Output:
7 is odd.
Example 2 (Medium): Is the service running?#
Compare typed text and print one of two results. File: L1-M4-ex2-if-else.sh
#!/bin/bash
read -r -p "Status of nginx (running/stopped): " status
if [[ "$status" == "running" ]]; then
echo "nginx is up. No action needed."
else
echo "nginx is not running. Please start it."
fi
Run it:
printf 'stopped\n' | bash L1-M4-ex2-if-else.sh
Output:
nginx is not running. Please start it.
Example 3 (A bit harder): Check each server's memory#
if-else inside a for loop gives every item its own result. File: L1-M4-ex3-if-else.sh
#!/bin/bash
for mem in 40 75 92; do
if (( mem > 80 )); then
echo "Memory ${mem}%: HIGH"
else
echo "Memory ${mem}%: OK"
fi
done
Run it:
bash L1-M4-ex3-if-else.sh
Output:
Memory 40%: OK
Memory 75%: OK
Memory 92%: HIGH
Common mistakes#
- Putting a condition after
else(else (( x > 5 ))).elsetakes no condition; useeliffor that. - Duplicating the same command in both branches. Move shared lines after
fi.
L1-M5: if-elif-else#
elif (else if) checks more conditions in order. Bash runs the first block whose condition is true and skips the rest, so put the most specific or highest check first.
Key syntax
if COND1; then
...
elif COND2; then
...
else
... # nothing matched
fi
Example 1 (Simple): Label disk usage#
The first matching condition wins. File: L1-M5-ex1-if-elif-else.sh
#!/bin/bash
usage=65
if (( usage >= 90 )); then
echo "Critical"
elif (( usage >= 70 )); then
echo "Warning"
else
echo "Healthy"
fi
Run it:
bash L1-M5-ex1-if-elif-else.sh
Output:
Healthy
Example 2 (Medium): Pick an instance size#
Choose a size from the number of users typed in. File: L1-M5-ex2-if-elif-else.sh
#!/bin/bash
read -r -p "How many users? " users
if (( users <= 10 )); then
echo "Use t3.micro"
elif (( users <= 100 )); then
echo "Use t3.medium"
else
echo "Use t3.large"
fi
Run it:
printf '50\n' | bash L1-M5-ex2-if-elif-else.sh
Output:
Use t3.medium
Example 3 (A bit harder): Simple environment menu#
A menu with a check for wrong choices. File: L1-M5-ex3-if-elif-else.sh
#!/bin/bash
echo "1) dev"
echo "2) test"
echo "3) prod"
read -r -p "Choose an environment: " choice
if [[ "$choice" == "1" ]]; then
echo "Deploying to dev."
elif [[ "$choice" == "2" ]]; then
echo "Deploying to test."
elif [[ "$choice" == "3" ]]; then
echo "Deploying to prod. Be careful!"
else
echo "Invalid choice: $choice"
fi
Run it:
printf '3\n' | bash L1-M5-ex3-if-elif-else.sh
Output:
1) dev
2) test
3) prod
Deploying to prod. Be careful!
Common mistakes#
- Ordering checks badly: testing
>= 70before>= 90means 95 is labelled Warning, never Critical. - Writing
else if(two words). Bash needselif, or a whole new nestedif ... fi.
L1-M6: test, [ ] and [[ ]]#
test is a command that checks a condition and returns 0 (true) or 1 (false). [ ... ] is exactly the same command with a different look. The spaces inside the brackets are required, because [ is a command name.
[[ ... ]] is Bash's improved version. It does not split variables on spaces, it understands && and || inside, and it supports pattern matching (== *.log) and regular expressions (=~).
Advice: in Bash scripts use [[ ]] for text and file checks and (( )) for numbers. Know [ ] because you will read it everywhere, and it also works in plain sh.
Key syntax
test -f file # same as below
[ -f file ] # spaces after [ and before ] are required
[[ $name == web* ]] # pattern match (bash)
[[ $v =~ ^[0-9]+$ ]] # regex match (bash)
[[ -f a && -r a ]] # && inside [[ ]]
Example 1 (Simple): Three ways to write one check#
test, [ ] and [[ ]] all answer the same question here. File: L1-M6-ex1-three-ways.sh
#!/bin/bash
env="prod"
if test "$env" = "prod"; then echo "test says: production"; fi
if [ "$env" = "prod" ]; then echo "[ ] says: production"; fi
if [[ $env == "prod" ]]; then echo "[[ ]] says: production"; fi
Run it:
bash L1-M6-ex1-three-ways.sh
Output:
test says: production
[ ] says: production
[[ ]] says: production
Example 2 (Medium): What only [[ ]] can do#
Patterns, regular expressions and && inside one test. File: L1-M6-ex2-double-bracket.sh
#!/bin/bash
file="app-2026.log"
version="v2.14"
if [[ $file == *.log ]]; then
echo "$file is a log file (pattern match)"
fi
if [[ $version =~ ^v([0-9]+)\.([0-9]+)$ ]]; then
echo "Version OK: major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]}"
fi
if [[ $file == app-* && $version == v2* ]]; then
echo "Both conditions are true"
fi
Run it:
bash L1-M6-ex2-double-bracket.sh
Output:
app-2026.log is a log file (pattern match)
Version OK: major=2 minor=14
Both conditions are true
Example 3 (A bit harder): Why [[ ]] is safer with empty values#
With [ ] an empty, unquoted variable disappears and breaks the test. [[ ]] handles it. File: L1-M6-ex3-safer-brackets.sh
#!/bin/bash
empty=""
# Deliberately unquoted inside [ ] to show the problem
# shellcheck disable=SC2086
[ $empty = "yes" ]
echo "[ ] with an unquoted empty variable: exit status $? (2 = error, not just false)"
if [ "$empty" = "yes" ]; then echo "matched"; else echo "[ ] with quotes works: not yes"; fi
if [[ $empty = "yes" ]]; then echo "matched"; else echo "[[ ]] works even without quotes: not yes"; fi
Run it:
bash L1-M6-ex3-safer-brackets.sh
Output:
L1-M6-ex3-safer-brackets.sh: line 6: [: =: unary operator expected
[ ] with an unquoted empty variable: exit status 2 (2 = error, not just false)
[ ] with quotes works: not yes
[[ ]] works even without quotes: not yes
Note: The error line is expected. It shows exactly the bug that
[[ ]]or quoting prevents.
Common mistakes#
- No spaces:
["$a" = "$b"]fails withcommand not found. You need[ "$a" = "$b" ]. - Using
==inside[ ]in scripts meant forsh. In POSIX[ ]the operator is=. Bash accepts both. - Quoting the right side of
=~([[ $v =~ "^[0-9]+$" ]]). Quotes turn the regex into plain text. Leave the pattern unquoted, or store it in a variable. - Expecting
[[ ]]to work inshordash. It is Bash/ksh/zsh only.
L1-M7: Integer comparison#
Inside test or [ ] numbers are compared with letter operators. Inside (( )) you can use the normal symbols instead.
[ ] |
(( )) |
Meaning |
|---|---|---|
-eq |
== |
equal |
-ne |
!= |
not equal |
-gt |
> |
greater than |
-ge |
>= |
greater or equal |
-lt |
< |
less than |
-le |
<= |
less or equal |
These work on whole numbers only. Bash cannot compare decimals like 2.5 natively (use awk or bc for that).
Key syntax
[ "$a" -eq "$b" ] # equal
[ "$a" -lt 100 ] # less than
(( a >= b )) # same idea with symbols
Example 1 (Simple): Compare two numbers#
Use -eq and -ne to compare two values. File: L1-M7-ex1-integer-comparison.sh
#!/bin/bash
running=3
expected=3
if [ "$running" -eq "$expected" ]; then
echo "All $expected servers are running."
fi
if [ "$running" -ne 0 ]; then
echo "At least one server is up."
fi
Run it:
bash L1-M7-ex1-integer-comparison.sh
Output:
All 3 servers are running.
At least one server is up.
Example 2 (Medium): Check open connections against a limit#
Use -lt and -ge with a typed number. File: L1-M7-ex2-integer-comparison.sh
#!/bin/bash
limit=100
read -r -p "Open connections: " conn
if [ "$conn" -lt "$limit" ]; then
echo "$conn is under the limit of $limit."
fi
if [ "$conn" -ge "$limit" ]; then
echo "$conn has reached the limit of $limit."
fi
Run it:
printf '120\n' | bash L1-M7-ex2-integer-comparison.sh
Output:
120 has reached the limit of 100.
Example 3 (A bit harder): Count servers above a threshold#
Use -gt inside a loop and keep a count. File: L1-M7-ex3-integer-comparison.sh
#!/bin/bash
threshold=70
high=0
for usage in 55 72 90 40 81; do
if [ "$usage" -gt "$threshold" ]; then
echo "Usage $usage is above $threshold"
((high++))
fi
done
echo "Servers above threshold: $high"
Run it:
bash L1-M7-ex3-integer-comparison.sh
Output:
Usage 72 is above 70
Usage 90 is above 70
Usage 81 is above 70
Servers above threshold: 3
Common mistakes#
- Using
==or>for numbers inside[ ].==compares text (so05and5differ) and>is a redirection. - Comparing decimals:
[ 2.5 -gt 2 ]givesinteger expression expected. - Comparing an empty variable:
[ "" -eq 0 ]is an error. Validate input first.
L1-M8: String comparison#
Text is compared with == (or =) and !=. -z is true when a string is empty (zero length) and -n is true when it is not empty.
Inside [[ ]], an unquoted right-hand side is treated as a pattern: [[ $line == ERROR* ]] matches anything starting with ERROR. Quote it ("ERROR*") to match literally.
Key syntax
[[ "$a" == "$b" ]] # same text
[[ "$a" != "$b" ]] # different
[[ -z "$a" ]] # empty
[[ -n "$a" ]] # not empty
[[ $a == web* ]] # starts with web
Example 1 (Simple): Check the region#
Use != to see if text is different. File: L1-M8-ex1-string-comparison.sh
#!/bin/bash
region="ap-south-1"
if [[ "$region" != "us-east-1" ]]; then
echo "You are not in us-east-1. You are in $region."
fi
Run it:
bash L1-M8-ex1-string-comparison.sh
Output:
You are not in us-east-1. You are in ap-south-1.
Example 2 (Medium): Simple login check#
Compare typed text and check for empty input. File: L1-M8-ex2-string-comparison.sh
#!/bin/bash
read -r -p "Username: " user
if [[ -z "$user" ]]; then
echo "No username entered."
elif [[ "$user" == "admin" ]]; then
echo "Welcome, administrator."
else
echo "Welcome, $user."
fi
Run it:
printf 'student1\n' | bash L1-M8-ex2-string-comparison.sh
Output:
Welcome, student1.
Example 3 (A bit harder): Find error lines in a log#
Match text patterns with == and * inside [[ ]]. (Arrays come in Level 3.) File: L1-M8-ex3-string-comparison.sh
#!/bin/bash
lines=("INFO server started" "ERROR disk full" "INFO user login" "ERROR timeout")
errors=0
for line in "${lines[@]}"; do
if [[ "$line" == ERROR* ]]; then
echo "Found: $line"
((errors++))
fi
done
echo "Total errors: $errors"
Run it:
bash L1-M8-ex3-string-comparison.sh
Output:
Found: ERROR disk full
Found: ERROR timeout
Total errors: 2
Common mistakes#
- Forgetting that comparisons are case-sensitive:
Yesis notyes. Lowercase first with${answer,,}. - Leaving out spaces around
==:[[ $a==$b ]]is one non-empty word and is always true! - Using
-eqfor text:[ "abc" -eq "abc" ]errors because -eq wants numbers.
L1-M9: File tests#
File tests ask questions about paths. They are the most common checks in real scripts: does the config exist before we read it? Is the folder there before we write to it?
| Test | True when the path... |
|---|---|
-e |
exists (any type) |
-f |
is a regular file |
-d |
is a directory |
-r |
is readable by you |
-w |
is writable by you |
-x |
is executable (or a folder you can enter) |
-s |
exists and is not empty |
Put ! in front to reverse a test: [ ! -d "$dir" ] means "the folder does not exist".
Key syntax
[ -f "$file" ] # regular file?
[ -d "$dir" ] # directory?
[ ! -e "$path" ] # does NOT exist
[[ -r $f && -s $f ]] # readable and not empty
Example 1 (Simple): Check if a folder exists#
test -d checks for a directory. File: L1-M9-ex1-test-dir.sh
#!/bin/bash
if test -d /tmp; then
echo "/tmp is a directory."
fi
Run it:
bash L1-M9-ex1-test-dir.sh
Output:
/tmp is a directory.
Example 2 (Medium): Is the file empty?#
test -s is true only when a file has content. File: L1-M9-ex2-test-empty.sh
#!/bin/bash
work_dir=$(mktemp -d)
touch "$work_dir/empty.log"
echo "error found" > "$work_dir/app.log"
for file in "$work_dir/empty.log" "$work_dir/app.log"; do
name=$(basename "$file")
if test -s "$file"; then
echo "$name has content."
else
echo "$name is empty."
fi
done
rm -r "$work_dir"
Run it:
bash L1-M9-ex2-test-empty.sh
Output:
empty.log is empty.
app.log has content.
Example 3 (A bit harder): A file inspector#
Run all seven tests against a file, a script, a folder and a missing path. File: L1-M9-ex3-file-inspector.sh
#!/bin/bash
work_dir=$(mktemp -d)
echo "port=8080" > "$work_dir/app.conf"
printf '#!/bin/bash\necho hi\n' > "$work_dir/deploy.sh"
chmod +x "$work_dir/deploy.sh"
mkdir "$work_dir/logs"
printf "%-12s %-3s %-3s %-3s %-3s %-3s %-3s %-3s\n" "PATH" "-e" "-f" "-d" "-r" "-w" "-x" "-s"
for name in app.conf deploy.sh logs missing.txt; do
path="$work_dir/$name"
row=""
for t in e f d r w x s; do
if test "-$t" "$path"; then row+="yes "; else row+="no "; fi
done
printf "%-12s %s\n" "$name" "$row"
done
rm -r "$work_dir"
Run it:
bash L1-M9-ex3-file-inspector.sh
Output:
PATH -e -f -d -r -w -x -s
app.conf yes yes no yes yes no yes
deploy.sh yes yes no yes yes yes yes
logs yes no yes yes yes yes yes
missing.txt no no no no no no no
Note: Run as a normal user. The root user can read and write almost everything, so
-rand-wbehave differently for root.
Common mistakes#
- Forgetting quotes:
[ -f $file ]breaks when the name has spaces or is empty. - Using
-ewhen you really need-f. A folder with that name also passes-e. - Testing with relative paths from cron or another folder. The current folder is not what you expect, so prefer absolute paths.
L1-M10: case#
case compares one value against a list of patterns and runs the first match. It is cleaner than a long chain of elif when you are checking one variable.
Each branch ends with ;;. Patterns can use | for "or", * for any text, and [...] for a set of characters. A final *) catches everything else, like else.
The block closes with esac (case backwards).
Key syntax
case "$value" in
start|begin) echo "starting" ;;
*.log) echo "a log file" ;;
[0-9]*) echo "starts with a digit" ;;
*) echo "anything else" ;;
esac
Example 1 (Simple): Yes or no#
Several spellings share one branch with |. File: L1-M10-ex1-yes-no.sh
#!/bin/bash
read -r -p "Continue? (y/n) " answer
case "$answer" in
y|Y|yes|YES) echo "Continuing..." ;;
n|N|no|NO) echo "Stopped by user." ;;
*) echo "Please answer y or n." ;;
esac
Run it:
printf 'Y\n' | bash L1-M10-ex1-yes-no.sh
Output:
Continuing...
Example 2 (Medium): Sort files by extension#
Patterns like *.log and *.tar.gz match parts of a name. File: L1-M10-ex2-by-extension.sh
#!/bin/bash
for file in app.log backup.tar.gz deploy.sh notes.txt db.log; do
case "$file" in
*.log) echo "$file -> logs/" ;;
*.tar.gz) echo "$file -> archives/" ;;
*.sh) echo "$file -> scripts/" ;;
*) echo "$file -> other/" ;;
esac
done
Run it:
bash L1-M10-ex2-by-extension.sh
Output:
app.log -> logs/
backup.tar.gz -> archives/
deploy.sh -> scripts/
notes.txt -> other/
db.log -> logs/
Example 3 (A bit harder): A service controller#
Handle actions like a real init script. Unknown actions get a usage message and a count. File: L1-M10-ex3-service-controller.sh
#!/bin/bash
status="stopped"
errors=0
for action in start status restart reload stop status; do
case "$action" in
start)
status="running"; echo "[start] service is now $status" ;;
stop)
status="stopped"; echo "[stop] service is now $status" ;;
restart)
echo "[restart] stopping... starting..."; status="running" ;;
status)
echo "[status] service is $status" ;;
*)
echo "[error] unknown action '$action'. Use: start|stop|restart|status"
((errors++)) ;;
esac
done
echo "Unknown actions: $errors"
Run it:
bash L1-M10-ex3-service-controller.sh
Output:
[start] service is now running
[status] service is running
[restart] stopping... starting...
[error] unknown action 'reload'. Use: start|stop|restart|status
[stop] service is now stopped
[status] service is stopped
Unknown actions: 1
Common mistakes#
- Forgetting
;;at the end of a branch, which causes a syntax error. - Putting
*)first: it matches everything, so later branches never run. - Quoting patterns:
"*.log")matches only the literal text*.log. Leave patterns unquoted, but do quote the value you test:case "$file" in.
Level 1 practice exercises#
Try each one yourself before opening the solution.
Exercise 1#
Read a whole number and print whether it is positive, negative or zero. Test with printf -- '-4\n' | bash solution.sh
Hint: if / elif / else with (( )).
Show solution
File: L1-P1-solution.sh
#!/bin/bash
read -r -p "Enter a whole number: " n
if (( n > 0 )); then
echo "$n is positive"
elif (( n < 0 )); then
echo "$n is negative"
else
echo "$n is zero"
fi
Run it:
printf -- '-4\n' | bash L1-P1-solution.sh
Output:
-4 is negative
Exercise 2#
Create a temp folder containing a file a.txt and a folder data. For each of a.txt, data and nope, print whether it is a file, a directory or missing.
Hint: -f, then -d, then else.
Show solution
File: L1-P2-solution.sh
#!/bin/bash
work_dir=$(mktemp -d)
touch "$work_dir/a.txt"
mkdir "$work_dir/data"
for name in a.txt data nope; do
path="$work_dir/$name"
if [ -f "$path" ]; then
echo "$name: file"
elif [ -d "$path" ]; then
echo "$name: directory"
else
echo "$name: missing"
fi
done
rm -r "$work_dir"
Run it:
bash L1-P2-solution.sh
Output:
a.txt: file
data: directory
nope: missing
Exercise 3#
Read a grade letter (A, B, C or F) and print a message using case. Accept lowercase too. Test with printf 'b\n' | bash solution.sh
Hint: Use A|a) style patterns.
Show solution
File: L1-P3-solution.sh
#!/bin/bash
read -r -p "Grade: " grade
case "$grade" in
A|a) echo "Excellent!" ;;
B|b) echo "Good work." ;;
C|c) echo "You passed." ;;
F|f) echo "Let's practise together." ;;
*) echo "Unknown grade: $grade" ;;
esac
Run it:
printf 'b\n' | bash L1-P3-solution.sh
Output:
Good work.
Exercise 4#
Write root:x:0:0 and student:x:1000:1000 into a temp file. Use grep -q and its exit status (no output parsing) to report whether the users student and guest are present.
Hint: if grep -q "^name:" file; then ...
Show solution
File: L1-P4-solution.sh
#!/bin/bash
work_dir=$(mktemp -d)
printf 'root:x:0:0\nstudent:x:1000:1000\n' > "$work_dir/users.txt"
for user in student guest; do
if grep -q "^$user:" "$work_dir/users.txt"; then
echo "$user: present"
else
echo "$user: not found"
fi
done
rm -r "$work_dir"
Run it:
bash L1-P4-solution.sh
Output:
student: present
guest: not found
Exercise 5#
Read a password and a confirmation (two lines). Print an error if the first is empty, Passwords match if they are equal, otherwise Passwords differ. Test with printf 's3cret\ns3cret\n' | bash solution.sh
Hint: -z first, then == .
Show solution
File: L1-P5-solution.sh
#!/bin/bash
read -r -s -p "Password: " pass1
echo
read -r -s -p "Confirm: " pass2
echo
if [[ -z "$pass1" ]]; then
echo "Error: password cannot be empty"
elif [[ "$pass1" == "$pass2" ]]; then
echo "Passwords match"
else
echo "Passwords differ"
fi
Run it:
printf 's3cret\ns3cret\n' | bash L1-P5-solution.sh
Output:
Passwords match
Note: The two blank lines come from the
echoafter each hiddenread -s. When you type for real, they move the cursor to a new line after the hidden password.
Level 1: check yourself#
Five multiple-choice questions cover Level 1. Each answer comes with an explanation of why every option is right or wrong, and you earn XP as you go.
Level 2: Loops#
Repeat work: for, while, until, break, continue, files, $( ) and $(( ))
Computers are great at doing the same thing many times without getting bored. Loops are how you ask them to.
In this level you will loop over lists, numbers and files, control loops with break and continue, capture command output and do maths.
L2-M1: Loops: the big picture#
A loop repeats a block of commands. Bash has three:
forrepeats once for each item in a list.whilerepeats while a condition is true.untilrepeats until a condition becomes true (the opposite of while).
The repeated block always sits between do and done. Each trip through the block is called an iteration.
Key syntax
for item in a b c; do ...; done
while CONDITION; do ...; done
until CONDITION; do ...; done
Example 1 (Simple): List some services#
A for loop repeats once per word. File: L2-M1-ex1-loops.sh
#!/bin/bash
for service in nginx sshd cron; do
echo "Checking $service"
done
Run it:
bash L2-M1-ex1-loops.sh
Output:
Checking nginx
Checking sshd
Checking cron
Example 2 (Medium): Countdown before a restart#
A while loop repeats while a condition stays true. File: L2-M1-ex2-loops.sh
#!/bin/bash
seconds=3
while (( seconds > 0 )); do
echo "Restarting in $seconds..."
((seconds--))
done
echo "Restarted!"
Run it:
bash L2-M1-ex2-loops.sh
Output:
Restarting in 3...
Restarting in 2...
Restarting in 1...
Restarted!
Example 3 (A bit harder): Same task, three loops#
for, while and until can all count from 1 to 3. File: L2-M1-ex3-loops.sh
#!/bin/bash
echo "for:"
for i in 1 2 3; do echo " $i"; done
echo "while:"
i=1
while (( i <= 3 )); do echo " $i"; ((i++)); done
echo "until:"
i=1
until (( i > 3 )); do echo " $i"; ((i++)); done
Run it:
bash L2-M1-ex3-loops.sh
Output:
for:
1
2
3
while:
1
2
3
until:
1
2
3
Common mistakes#
- Forgetting
doordone. - Creating an endless loop by never changing the variable the condition checks (forgetting
((i++))). Press Ctrl+C to escape.
L2-M2: The for loop#
for walks through a list and puts each item in a variable, one at a time.
- Lists:
for s in web01 db01; do - Ranges:
{1..5}expands to1 2 3 4 5;{0..20..5}counts in steps of 5. - Files:
for f in *.log; doloops over matching file names (this is called globbing). - C-style:
for (( i = 0; i < 5; i++ )); dogives you full control over start, stop and step, which is handy with variables.
Key syntax
for name in a b c; do echo "$name"; done
for n in {1..5}; do echo "$n"; done
for f in *.log; do echo "$f"; done
for (( i = 0; i < 3; i++ )); do echo "$i"; done
Example 1 (Simple): Count with a range#
{1..5} makes a list of numbers for you. File: L2-M2-ex1-for.sh
#!/bin/bash
for n in {1..5}; do
echo "Server web0$n"
done
Run it:
bash L2-M2-ex1-for.sh
Output:
Server web01
Server web02
Server web03
Server web04
Server web05
Example 2 (Medium): Loop over files in a folder#
Use a wildcard to visit every .log file. File: L2-M2-ex2-for.sh
#!/bin/bash
work_dir=$(mktemp -d)
touch "$work_dir/app.log" "$work_dir/db.log" "$work_dir/web.log"
for file in "$work_dir"/*.log; do
echo "Found log: $(basename "$file")"
done
rm -r "$work_dir"
Run it:
bash L2-M2-ex2-for.sh
Output:
Found log: app.log
Found log: db.log
Found log: web.log
Example 3 (A bit harder): Add up disk usage#
A C-style for loop with an array and a running total. File: L2-M2-ex3-for.sh
#!/bin/bash
sizes=(120 340 75 500)
total=0
for (( i = 0; i < ${#sizes[@]}; i++ )); do
echo "Disk $((i + 1)): ${sizes[i]} GB"
(( total += sizes[i] ))
done
echo "Total: $total GB"
Run it:
bash L2-M2-ex3-for.sh
Output:
Disk 1: 120 GB
Disk 2: 340 GB
Disk 3: 75 GB
Disk 4: 500 GB
Total: 1035 GB
Common mistakes#
- Writing
{1..$n}. Ranges do not expand variables. Usefor (( i = 1; i <= n; i++ ))orseq 1 "$n". - Looping over
$(ls), which breaks on spaces in file names. Use a glob instead:for f in *.txt. - Not handling "no matches": if no
.logfiles exist,*.logstays as literal text. Check with[ -e "$f" ] || continue, or setshopt -s nullglob.
L2-M3: The while loop#
while keeps looping as long as its condition command succeeds. Use it when you do not know in advance how many times to repeat: reading input, waiting, retrying, menus.
Key syntax
while (( count < 5 )); do
((count++))
done
while read -r line; do ... ; done < file
Example 1 (Simple): Print even numbers#
while with a step of 2. File: L2-M3-ex1-while.sh
#!/bin/bash
n=2
while (( n <= 10 )); do
echo "$n"
(( n += 2 ))
done
Run it:
bash L2-M3-ex1-while.sh
Output:
2
4
6
8
10
Example 2 (Medium): Read a file line by line#
while read is the standard way to process a file. File: L2-M3-ex2-while.sh
#!/bin/bash
work_dir=$(mktemp -d)
printf "web01\ndb01\ncache01\n" > "$work_dir/servers.txt"
count=0
while read -r server; do
((count++))
echo "$count. $server"
done < "$work_dir/servers.txt"
echo "Total servers: $count"
rm -r "$work_dir"
Run it:
bash L2-M3-ex2-while.sh
Output:
1. web01
2. db01
3. cache01
Total servers: 3
Example 3 (A bit harder): Menu that repeats until quit#
A while loop keeps showing a menu until the user types q. File: L2-M3-ex3-while.sh
#!/bin/bash
choice=""
while [[ "$choice" != "q" ]]; do
read -r -p "Type d (disk), u (uptime) or q (quit): " choice
if [[ "$choice" == "d" ]]; then
echo "Disk check selected."
elif [[ "$choice" == "u" ]]; then
echo "Uptime check selected."
elif [[ "$choice" == "q" ]]; then
echo "Goodbye!"
else
echo "Unknown option: $choice"
fi
done
Run it:
printf 'd\nx\nu\nq\n' | bash L2-M3-ex3-while.sh
Output:
Disk check selected.
Unknown option: x
Uptime check selected.
Goodbye!
Common mistakes#
- Forgetting to update the counter, which makes the loop endless.
- Using
while [ $i < 5 ]:<is a redirection inside[ ]. Use(( i < 5 ))or-lt.
L2-M4: The until loop#
until is while turned inside out: it loops as long as the condition is false and stops as soon as it becomes true. It reads naturally for "wait until X happens".
Key syntax
until CONDITION; do
commands
done
Example 1 (Simple): Count down to zero#
until stops when the condition becomes true. File: L2-M4-ex1-until.sh
#!/bin/bash
n=3
until (( n == 0 )); do
echo "$n"
((n--))
done
echo "Done"
Run it:
bash L2-M4-ex1-until.sh
Output:
3
2
1
Done
Example 2 (Medium): Retry a connection#
Simulate retries until attempt 3 succeeds. File: L2-M4-ex2-until.sh
#!/bin/bash
attempt=1
connected=no
until [[ "$connected" == "yes" ]]; do
echo "Attempt $attempt: connecting..."
if (( attempt == 3 )); then
connected=yes
echo "Connected!"
fi
((attempt++))
done
Run it:
bash L2-M4-ex2-until.sh
Output:
Attempt 1: connecting...
Attempt 2: connecting...
Attempt 3: connecting...
Connected!
Example 3 (A bit harder): Wait until a file appears#
Keep checking until a file exists (we create it on the second pass). File: L2-M4-ex3-until.sh
#!/bin/bash
work_dir=$(mktemp -d)
flag="$work_dir/ready.flag"
checks=0
until [ -f "$flag" ]; do
((checks++))
echo "Check $checks: file not ready yet."
if (( checks == 2 )); then
touch "$flag"
fi
done
echo "File is ready after $checks checks."
rm -r "$work_dir"
Run it:
bash L2-M4-ex3-until.sh
Output:
Check 1: file not ready yet.
Check 2: file not ready yet.
File is ready after 2 checks.
Common mistakes#
- Mixing up while and until, which flips the logic. Read it aloud: "until the file exists, keep checking."
- Waiting forever. Real wait loops need a maximum number of tries and a
sleepbetween checks.
L2-M5: break: leave a loop early#
break jumps out of the loop immediately; the script continues after done. Use it when you have found what you were looking for, or when something has gone wrong.
break 2 leaves two nested loops at once.
Key syntax
for x in ...; do
if FOUND; then break; fi
done
Example 1 (Simple): Stop at the first failure#
break leaves the loop as soon as we see FAIL. File: L2-M5-ex1-break.sh
#!/bin/bash
for result in OK OK FAIL OK; do
if [[ "$result" == "FAIL" ]]; then
echo "Failure found. Stopping."
break
fi
echo "Result: $result"
done
Run it:
bash L2-M5-ex1-break.sh
Output:
Result: OK
Result: OK
Failure found. Stopping.
Example 2 (Medium): Find a free port#
Search a list and stop when a match is found. File: L2-M5-ex2-break.sh
#!/bin/bash
used_ports=(80 443 8080)
for port in 8080 8081 8082; do
in_use=no
for used in "${used_ports[@]}"; do
if (( port == used )); then
in_use=yes
fi
done
if [[ "$in_use" == "no" ]]; then
echo "Free port found: $port"
break
fi
echo "Port $port is busy."
done
Run it:
bash L2-M5-ex2-break.sh
Output:
Port 8080 is busy.
Free port found: 8081
Example 3 (A bit harder): Three password tries#
break out of an endless loop on the right answer. File: L2-M5-ex3-break.sh
#!/bin/bash
tries=0
while true; do
read -r -p "Password: " pass
((tries++))
if [[ "$pass" == "aws123" ]]; then
echo "Access granted."
break
fi
if (( tries == 3 )); then
echo "Too many tries."
break
fi
echo "Wrong password. Try again."
done
Run it:
printf 'hello\naws123\n' | bash L2-M5-ex3-break.sh
Output:
Wrong password. Try again.
Access granted.
Common mistakes#
- Expecting
breakto exit the script. It only leaves the loop; useexitto stop the script. - Inside nested loops,
breakleaves only the innermost loop.
L2-M6: continue: skip to the next item#
continue skips the rest of the current iteration and jumps straight to the next item. It is perfect for filtering: "ignore this one and keep going".
Key syntax
for x in ...; do
if SKIP_THIS; then continue; fi
process "$x"
done
Example 1 (Simple): Skip odd numbers#
continue jumps to the next number. File: L2-M6-ex1-continue.sh
#!/bin/bash
for n in 1 2 3 4 5 6; do
if (( n % 2 != 0 )); then
continue
fi
echo "$n"
done
Run it:
bash L2-M6-ex1-continue.sh
Output:
2
4
6
Example 2 (Medium): Skip servers under maintenance#
Skip one item and process the rest. File: L2-M6-ex2-continue.sh
#!/bin/bash
for server in web01 web02 db01 web03; do
if [[ "$server" == "db01" ]]; then
echo "Skipping $server (maintenance)"
continue
fi
echo "Updating $server"
done
Run it:
bash L2-M6-ex2-continue.sh
Output:
Updating web01
Updating web02
Skipping db01 (maintenance)
Updating web03
Example 3 (A bit harder): Ignore blank and comment lines#
Skip empty lines and lines that start with #. File: L2-M6-ex3-continue.sh
#!/bin/bash
work_dir=$(mktemp -d)
printf "# server list\nweb01\n\ndb01\n# end\n" > "$work_dir/hosts.txt"
while read -r line; do
if [[ -z "$line" || "$line" == \#* ]]; then
continue
fi
echo "Host: $line"
done < "$work_dir/hosts.txt"
rm -r "$work_dir"
Run it:
bash L2-M6-ex3-continue.sh
Output:
Host: web01
Host: db01
Common mistakes#
- Putting
continuein awhileloop before the counter update, so the counter never changes and the loop never ends. - Confusing continue (skip one) with break (stop all).
L2-M7: Reading a file line by line#
The safe pattern is while IFS= read -r line; do ... done < file.
IFS=stopsreadfrom trimming spaces at the start and end of the line. (IFS is the Internal Field Separator, the characters Bash splits on.)-rkeeps backslashes.< fileafterdonefeeds the file into the whole loop.
Set IFS=, to split CSV columns into several variables. Watch out for two traps: a last line with no newline at the end is skipped unless you add || [[ -n $line ]], and piping into while runs the loop in a subshell (a child copy of the shell), so variables you change inside are lost afterwards.
Key syntax
while IFS= read -r line; do
echo "$line"
done < file.txt
while IFS=, read -r name cpu mem; do ...; done < data.csv
while IFS= read -r line || [[ -n $line ]]; do ...; done < file
Example 1 (Simple): Print a to-do list#
Each line of the file becomes one loop pass. Leading spaces are kept thanks to IFS=. File: L2-M7-ex1-todo.sh
#!/bin/bash
work_dir=$(mktemp -d)
printf "Patch web servers\n Rotate logs\nUpdate README\n" > "$work_dir/todo.txt"
n=0
while IFS= read -r task; do
((n++))
echo "[$n] $task"
done < "$work_dir/todo.txt"
rm -r "$work_dir"
Run it:
bash L2-M7-ex1-todo.sh
Output:
[1] Patch web servers
[2] Rotate logs
[3] Update README
Example 2 (Medium): Parse a CSV report#
IFS=, splits each line into columns. We skip the header and flag busy servers. File: L2-M7-ex2-csv.sh
#!/bin/bash
work_dir=$(mktemp -d)
cat > "$work_dir/servers.csv" <<'CSV'
name,cpu,mem
web01,35,60
db01,91,88
cache01,12,40
CSV
{
read -r _header
while IFS=, read -r name cpu mem; do
if (( cpu > 80 || mem > 80 )); then
echo "ALERT $name cpu=${cpu}% mem=${mem}%"
else
echo "ok $name"
fi
done
} < "$work_dir/servers.csv"
rm -r "$work_dir"
Run it:
bash L2-M7-ex2-csv.sh
Output:
ok web01
ALERT db01 cpu=91% mem=88%
ok cache01
Example 3 (A bit harder): Two classic traps#
Trap 1: the last line has no newline at the end. Trap 2: piping into while loses the count. File: L2-M7-ex3-traps.sh
#!/bin/bash
work_dir=$(mktemp -d)
printf "alpha\nbeta\ngamma" > "$work_dir/list.txt" # no newline after gamma
echo "Plain loop:"
while IFS= read -r item; do echo " $item"; done < "$work_dir/list.txt"
echo "Loop with || [[ -n \$item ]]:"
while IFS= read -r item || [[ -n $item ]]; do echo " $item"; done < "$work_dir/list.txt"
count=0
# shellcheck disable=SC2002
cat "$work_dir/list.txt" | while IFS= read -r item || [[ -n $item ]]; do
# shellcheck disable=SC2030
((count++))
done
# shellcheck disable=SC2031
echo "Count after piping into while: $count (lost in a subshell)"
count=0
while IFS= read -r item || [[ -n $item ]]; do ((count++)); done < "$work_dir/list.txt"
echo "Count with < redirect: $count"
rm -r "$work_dir"
Run it:
bash L2-M7-ex3-traps.sh
Output:
Plain loop:
alpha
beta
Loop with || [[ -n $item ]]:
alpha
beta
gamma
Count after piping into while: 0 (lost in a subshell)
Count with < redirect: 3
Common mistakes#
- Writing
for line in $(cat file). That loops over WORDS, not lines, and expands wildcards. - Forgetting
-r, orIFS=when spaces matter. - Piping into the loop (
cat f | while ...) and expecting variables to survive. Usedone < file. - Calling a command inside the loop that also reads standard input (like
ssh), which swallows the rest of the file. Usessh -nor read from another file descriptor:while read -r l <&3; do ...; done 3< file.
L2-M8: Command substitution $( )#
$(command) runs a command and drops its output into your line, usually to save it in a variable: today=$(date +%F).
Put double quotes around it when you use the result: "$(command)". You may see the old backtick form `command` in older scripts; $( ) is easier to read and can be nested.
Key syntax
now=$(date +%H:%M)
files=$(ls | wc -l)
echo "Kernel: $(uname -r)"
base=$(basename "$path")
Example 1 (Simple): Save the user name#
Store the output of whoami in a variable. File: L2-M8-ex1-command-substitution.sh
#!/bin/bash
user=$(whoami)
echo "Logged in as: $user"
Run it:
bash L2-M8-ex1-command-substitution.sh
Sample output (yours will differ: depends on who is logged in):
Logged in as: box
Example 2 (Medium): Count lines in a log file#
Use wc -l and grep -c inside $( ). File: L2-M8-ex2-command-substitution.sh
#!/bin/bash
work_dir=$(mktemp -d)
printf "ERROR a\nINFO b\nERROR c\nINFO d\nERROR e\n" > "$work_dir/app.log"
total=$(wc -l < "$work_dir/app.log")
errors=$(grep -c "ERROR" "$work_dir/app.log")
echo "Total lines: $total"
echo "Error lines: $errors"
rm -r "$work_dir"
Run it:
bash L2-M8-ex2-command-substitution.sh
Output:
Total lines: 5
Error lines: 3
Example 3 (A bit harder): Make a dated backup name#
Combine date output into a file name and check disk use. File: L2-M8-ex3-command-substitution.sh
#!/bin/bash
stamp=$(date +%Y-%m-%d)
backup="backup-$stamp.tar.gz"
echo "Backup file name: $backup"
used=$(df -P / | awk 'NR==2 {print $5}' | tr -d '%')
if (( used > 80 )); then
echo "Root disk is ${used}% full. Clean up first."
else
echo "Root disk is ${used}% full. OK to back up."
fi
Run it:
bash L2-M8-ex3-command-substitution.sh
Sample output (yours will differ: depends on today's date and your disk):
Backup file name: backup-2026-10-08.tar.gz
Root disk is 13% full. OK to back up.
Common mistakes#
- Adding spaces around
=:x = $(cmd)fails. - Forgetting the quotes, so multi-line output gets squashed onto one line or split up.
- Expecting
$( )to capture error messages. It only captures standard output; add2>&1if you want errors too.
L2-M9: Arithmetic $(( ))#
Bash does whole-number maths inside $(( )), which gives you a result, and (( )), which runs a calculation or test.
Operators: + - * / % (remainder) and ** (power). Division throws away the fraction: 7 / 2 is 3.
Inside (( )) you don't need $ before variable names, and you can write ((count++)) or ((total += n)).
Key syntax
sum=$(( a + b ))
(( count++ ))
(( total += size ))
pct=$(( used * 100 / total )) # multiply first!
hex=$(( 16#ff )) # 255
Example 1 (Simple): Basic calculator#
The five operators plus power. Note what happens with division. File: L2-M9-ex1-calculator.sh
#!/bin/bash
a=17
b=5
echo "$a + $b = $(( a + b ))"
echo "$a - $b = $(( a - b ))"
echo "$a * $b = $(( a * b ))"
echo "$a / $b = $(( a / b )) (whole numbers only)"
echo "$a % $b = $(( a % b )) (remainder)"
echo "2 ** 10 = $(( 2 ** 10 ))"
Run it:
bash L2-M9-ex1-calculator.sh
Output:
17 + 5 = 22
17 - 5 = 12
17 * 5 = 85
17 / 5 = 3 (whole numbers only)
17 % 5 = 2 (remainder)
2 ** 10 = 1024
Example 2 (Medium): Percentages the right way#
Multiply before dividing, otherwise integer division gives 0. File: L2-M9-ex2-percent.sh
#!/bin/bash
used=37
total=120
# Deliberately in the wrong order to show the bug
# shellcheck disable=SC2017
wrong=$(( used / total * 100 ))
right=$(( used * 100 / total ))
rounded=$(( (used * 100 + total / 2) / total ))
echo "Divide first: ${wrong}%"
echo "Multiply first: ${right}%"
echo "Rounded: ${rounded}%"
(( right > 25 )) && echo "Over a quarter used."
Run it:
bash L2-M9-ex2-percent.sh
Output:
Divide first: 0%
Multiply first: 30%
Rounded: 31%
Over a quarter used.
Example 3 (A bit harder): Uptime and size converter#
Turn seconds into h/m/s, bytes into MB with one decimal place, and read hex and binary numbers. File: L2-M9-ex3-converter.sh
#!/bin/bash
seconds=93784
days=$(( seconds / 86400 ))
hours=$(( seconds % 86400 / 3600 ))
mins=$(( seconds % 3600 / 60 ))
secs=$(( seconds % 60 ))
echo "Uptime: ${days}d ${hours}h ${mins}m ${secs}s"
bytes=5368709
tenths=$(( bytes * 10 / 1048576 ))
echo "Size: $(( tenths / 10 )).$(( tenths % 10 )) MB"
echo "Hex ff = $(( 16#ff )), binary 1010 = $(( 2#1010 ))"
Run it:
bash L2-M9-ex3-converter.sh
Output:
Uptime: 1d 2h 3m 4s
Size: 5.1 MB
Hex ff = 255, binary 1010 = 10
Common mistakes#
- Expecting decimals:
$(( 10 / 4 ))is 2, not 2.5. Useawk 'BEGIN{print 10/4}'for decimals. - Leading zeros:
$(( 08 + 1 ))fails because 08 is read as an (invalid) octal number. Strip zeros or use10#08. - Writing
$((a+b))with values that are not numbers. Validate input first.
Level 2 practice exercises#
Try each one yourself before opening the solution.
Exercise 1#
Print the multiplication table of 7 from 7 x 1 to 7 x 10 using a C-style for loop.
Hint: for (( i = 1; i <= 10; i++ ))
Show solution
File: L2-P1-solution.sh
#!/bin/bash
for (( i = 1; i <= 10; i++ )); do
echo "7 x $i = $(( 7 * i ))"
done
Run it:
bash L2-P1-solution.sh
Output:
7 x 1 = 7
7 x 2 = 14
7 x 3 = 21
7 x 4 = 28
7 x 5 = 35
7 x 6 = 42
7 x 7 = 49
7 x 8 = 56
7 x 9 = 63
7 x 10 = 70
Exercise 2#
Use a while loop to add up all the numbers from 1 to 100 and print the total.
Hint: Keep two variables: the counter and the running total.
Show solution
File: L2-P2-solution.sh
#!/bin/bash
n=1
total=0
while (( n <= 100 )); do
(( total += n ))
(( n++ ))
done
echo "Total: $total"
Run it:
bash L2-P2-solution.sh
Output:
Total: 5050
Exercise 3#
Create a temp file with server names, blank lines and # comments. Read it line by line, skip blanks and comments, print each server and the final count.
Hint: continue on blank or # lines; count the rest.
Show solution
File: L2-P3-solution.sh
#!/bin/bash
work_dir=$(mktemp -d)
printf "# prod\nweb01\n\nweb02\n# db\ndb01\n" > "$work_dir/servers.txt"
count=0
while IFS= read -r line; do
[[ -z $line || $line == \#* ]] && continue
echo "server: $line"
(( count++ ))
done < "$work_dir/servers.txt"
echo "Servers: $count"
rm -r "$work_dir"
Run it:
bash L2-P3-solution.sh
Output:
server: web01
server: web02
server: db01
Servers: 3
Exercise 4#
Loop over 5 -3 42 -1 150 8 99. Skip negative numbers, stop completely at the first number above 100, and print the others.
Hint: continue for negatives; break for > 100.
Show solution
File: L2-P4-solution.sh
#!/bin/bash
for n in 5 -3 42 -1 150 8 99; do
if (( n < 0 )); then
continue
fi
if (( n > 100 )); then
echo "Found $n (> 100), stopping."
break
fi
echo "Value: $n"
done
Run it:
bash L2-P4-solution.sh
Output:
Value: 5
Value: 42
Found 150 (> 100), stopping.
Exercise 5#
Convert 3725 seconds into the form 1h 2m 5s with $(( )).
Hint: / 3600 for hours, % 3600 / 60 for minutes, % 60 for seconds.
Show solution
File: L2-P5-solution.sh
#!/bin/bash
total=3725
echo "$(( total / 3600 ))h $(( total % 3600 / 60 ))m $(( total % 60 ))s"
Run it:
bash L2-P5-solution.sh
Output:
1h 2m 5s
Level 2: check yourself#
Five multiple-choice questions cover Level 2. Each answer comes with an explanation of why every option is right or wrong, and you earn XP as you go.
Level 3: Intermediate#
Organise and transform: functions, arguments, arrays, strings, here-docs, redirection
Your scripts are growing. This level gives you the tools to keep them tidy and powerful: reusable functions, command-line arguments, collections of values, text surgery, and full control over where output goes.
L3-M1: Functions and arguments#
A function is a named, reusable block of code. Define it once, then call it by name like any other command.
Inside a function, $1, $2, ... are the arguments passed to that function, and $# is how many there are.
Define functions before you call them, usually at the top of the script.
Key syntax
greet() {
echo "Hello, $1"
}
greet "Asha" # call it with an argument
Example 1 (Simple): Your first function#
Define it once and call it as often as you like. File: L3-M1-ex1-first-function.sh
#!/bin/bash
greet() {
echo "Hello, $1! Welcome to Bash."
}
greet "Asha"
greet "Ravi"
greet "class"
Run it:
bash L3-M1-ex1-first-function.sh
Output:
Hello, Asha! Welcome to Bash.
Hello, Ravi! Welcome to Bash.
Hello, class! Welcome to Bash.
Example 2 (Medium): Check the arguments#
A function can validate what it receives and refuse bad calls. File: L3-M1-ex2-function-args.sh
#!/bin/bash
add_user() {
if (( $# < 2 )); then
echo "add_user: need a name and a role (got $# argument(s))"
return 1
fi
echo "Added user '$1' with role '$2'"
}
add_user "meera" "admin"
add_user "john" "viewer"
add_user "solo" || echo "-> that call failed with status $?"
Run it:
bash L3-M1-ex2-function-args.sh
Output:
Added user 'meera' with role 'admin'
Added user 'john' with role 'viewer'
add_user: need a name and a role (got 1 argument(s))
-> that call failed with status 1
Example 3 (A bit harder): Small functions, clear script#
Three tiny functions turn a list of numbers into a readable report. File: L3-M1-ex3-report-functions.sh
#!/bin/bash
line() {
printf '%*s\n' 32 '' | tr ' ' '-'
}
check() {
local name=$1 value=$2 limit=$3
if (( value > limit )); then
printf "%-8s %3d%% over %d%%\n" "$name" "$value" "$limit"
else
printf "%-8s %3d%% ok\n" "$name" "$value"
fi
}
report() {
line
echo "Health report for $1"
line
check "cpu" "$2" 80
check "memory" "$3" 90
check "disk" "$4" 85
}
report web01 45 93 70
report db01 88 60 91
Run it:
bash L3-M1-ex3-report-functions.sh
Output:
--------------------------------
Health report for web01
--------------------------------
cpu 45% ok
memory 93% over 90%
disk 70% ok
--------------------------------
Health report for db01
--------------------------------
cpu 88% over 80%
memory 60% ok
disk 91% over 85%
Common mistakes#
- Calling a function before it is defined. Bash reads top to bottom.
- Using parentheses when calling: write
greet Asha, notgreet(Asha). - Confusing the script's
$1with the function's$1. Inside a function,$1is the function's own first argument.
L3-M2: return vs echo, and local variables#
Functions have two ways to give something back:
return Nsets the exit status (0 to 255). Use it for yes/no answers, so the function works directly inif.echoprints data, which the caller captures with$(my_function).
Variables in Bash are global by default: a function that sets name=x overwrites any name elsewhere in the script. Declare them with local inside functions to keep them private.
Key syntax
is_ok() { [[ $1 == ok ]]; } # status from the last command
if is_ok "$s"; then ...; fi
get_ext() { echo "${1##*.}"; } # data via echo
ext=$(get_ext report.pdf)
f() { local tmp=1; } # private variable
Example 1 (Simple): return for yes/no answers#
is_even returns 0 (true) or 1 (false), so if can use it directly. File: L3-M2-ex1-return-status.sh
#!/bin/bash
is_even() {
if (( $1 % 2 == 0 )); then
return 0
fi
return 1
}
for n in 4 7 10; do
if is_even "$n"; then
echo "$n is even"
else
echo "$n is odd"
fi
done
Run it:
bash L3-M2-ex1-return-status.sh
Output:
4 is even
7 is odd
10 is even
Example 2 (Medium): echo to return data#
Print the result and capture it with $( ). Status and data can be used together. File: L3-M2-ex2-echo-data.sh
#!/bin/bash
to_gb() {
echo $(( $1 / 1024 ))
}
file_type() {
case "$1" in
*.log) echo "log" ;;
*.sh) echo "script" ;;
*) echo "unknown"; return 1 ;;
esac
}
size_mb=20480
echo "Size: $(to_gb "$size_mb") GB"
for f in app.log deploy.sh photo.png; do
if kind=$(file_type "$f"); then
echo "$f is a $kind"
else
echo "$f: type is $kind (status $?)"
fi
done
Run it:
bash L3-M2-ex2-echo-data.sh
Output:
Size: 20 GB
app.log is a log
deploy.sh is a script
photo.png: type is unknown (status 1)
Example 3 (A bit harder): Why local matters#
Without local, a helper silently overwrites the caller's variable. File: L3-M2-ex3-local.sh
#!/bin/bash
count=100
no_local() {
count=0
for _ in a b c; do ((count++)); done
echo " inside no_local: count=$count"
}
with_local() {
local count=0
for _ in a b c; do ((count++)); done
echo " inside with_local: count=$count"
}
echo "Start: count=$count"
with_local
echo "After with_local: count=$count (safe)"
no_local
echo "After no_local: count=$count (overwritten!)"
Run it:
bash L3-M2-ex3-local.sh
Output:
Start: count=100
inside with_local: count=3
After with_local: count=100 (safe)
inside no_local: count=3
After no_local: count=3 (overwritten!)
Common mistakes#
- Trying to return text or big numbers with
return:return "hello"errors, andreturn 300wraps around to 44. Use echo for data. - Echoing debug messages inside a function whose output you capture; they end up in your variable. Send them to stderr with
>&2. - Writing
local x=$(cmd)and then checking$?, which only tells you whetherlocalworked. Writelocal x; x=$(cmd)instead.
L3-M3: Positional parameters: $1, $@, $#, shift#
When you run bash deploy.sh prod 3, the words after the script name become positional parameters: $1 is prod and $2 is 3.
$0is the script name.$#is the number of arguments."$@"is all arguments, each kept separate. Always use it with quotes."$*"is all arguments joined into one string.shiftthrows away$1and moves the others down ($2becomes$1). It is handy for processing options one by one.
Key syntax
bash script.sh one "two words" three
echo "$1" # one
echo "$#" # 3
for a in "$@"; do ... done
shift # drop $1
echo "${10}" # braces from 10 up
Example 1 (Simple): Meet your arguments#
Run the script with two arguments and look at each special variable. File: L3-M3-ex1-positional.sh
#!/bin/bash
echo "Script name: $0"
echo "First: $1"
echo "Second: $2"
echo "Count: $#"
echo "All: $*"
Run it:
bash L3-M3-ex1-positional.sh web01 db01
Output:
Script name: L3-M3-ex1-positional.sh
First: web01
Second: db01
Count: 2
All: web01 db01
Example 2 (Medium): "$@" vs "$*"#
One argument contains a space. "$@" keeps it whole; "$*" glues everything together. File: L3-M3-ex2-at-vs-star.sh
#!/bin/bash
echo "Got $# arguments."
echo 'Looping over "$@":'
for arg in "$@"; do
echo " [$arg]"
done
echo '"$*" is one single string:'
echo " [$*]"
Run it:
bash L3-M3-ex2-at-vs-star.sh 'my file.txt' notes.txt
Output:
Got 2 arguments.
Looping over "$@":
[my file.txt]
[notes.txt]
"$*" is one single string:
[my file.txt notes.txt]
Example 3 (A bit harder): Parse options with shift#
Walk through --env and --count options. Anything else is collected as an extra argument. File: L3-M3-ex3-shift-options.sh
#!/bin/bash
env="dev"
count=1
extras=()
while (( $# > 0 )); do
case "$1" in
--env) env=$2; shift 2 ;;
--count) count=$2; shift 2 ;;
--) shift; extras+=("$@"); break ;;
*) extras+=("$1"); shift ;;
esac
done
echo "env=$env"
echo "count=$count"
echo "extras (${#extras[@]}): ${extras[*]}"
Run it:
bash L3-M3-ex3-shift-options.sh --env prod --count 3 report.txt -- --not-an-option
Output:
env=prod
count=3
extras (2): report.txt --not-an-option
Common mistakes#
- Using
$@without quotes, which re-splits arguments that contain spaces. - Writing
$10and getting$1followed by a 0. Use${10}. - Not checking
$#before using$1, so the script runs with an empty value. Print a usage message instead.
L3-M4: Indexed arrays#
An array holds a list of values in one variable. Indexes start at 0.
Key syntax: arr=(a b c) creates one, ${arr[0]} reads one item, "${arr[@]}" gives every item (always quoted), ${#arr[@]} is the count, arr+=(d) appends, and unset 'arr[1]' removes an item.
mapfile -t lines < file (also called readarray) loads a file into an array, one line per item.
Key syntax
servers=(web01 web02 db01)
echo "${servers[0]}" # web01
echo "${#servers[@]}" # 3
servers+=(cache01) # append
for s in "${servers[@]}"; do ...; done
echo "${servers[@]:1:2}" # slice: 2 items from index 1
Example 1 (Simple): Create and read an array#
Index, count and print every item. File: L3-M4-ex1-array-basics.sh
#!/bin/bash
servers=(web01 web02 db01)
echo "First server: ${servers[0]}"
echo "Last server: ${servers[-1]}"
echo "How many: ${#servers[@]}"
echo "All: ${servers[*]}"
Run it:
bash L3-M4-ex1-array-basics.sh
Output:
First server: web01
Last server: db01
How many: 3
All: web01 web02 db01
Example 2 (Medium): Change an array#
Append, remove, slice and loop with indexes. File: L3-M4-ex2-array-change.sh
#!/bin/bash
regions=(us-east-1 eu-west-1)
regions+=(ap-south-1 ap-southeast-2)
echo "After append: ${regions[*]}"
unset 'regions[1]'
echo "After unset: ${regions[*]} (indexes: ${!regions[*]})"
regions=("${regions[@]}") # re-pack the indexes
echo "Repacked indexes: ${!regions[*]}"
echo "Slice [1..2]: ${regions[*]:1:2}"
for i in "${!regions[@]}"; do
echo " $i -> ${regions[i]}"
done
Run it:
bash L3-M4-ex2-array-change.sh
Output:
After append: us-east-1 eu-west-1 ap-south-1 ap-southeast-2
After unset: us-east-1 ap-south-1 ap-southeast-2 (indexes: 0 2 3)
Repacked indexes: 0 1 2
Slice [1..2]: ap-south-1 ap-southeast-2
0 -> us-east-1
1 -> ap-south-1
2 -> ap-southeast-2
Example 3 (A bit harder): Load a file into an array#
mapfile reads all lines at once; then we sort, remove duplicates and filter. File: L3-M4-ex3-mapfile.sh
#!/bin/bash
work_dir=$(mktemp -d)
printf "db01\nweb02\nweb01\ndb01\ncache01\nweb02\n" > "$work_dir/hosts.txt"
mapfile -t hosts < "$work_dir/hosts.txt"
echo "Loaded ${#hosts[@]} lines"
mapfile -t unique < <(printf '%s\n' "${hosts[@]}" | sort -u)
echo "Unique (${#unique[@]}): ${unique[*]}"
web=()
for h in "${unique[@]}"; do
[[ $h == web* ]] && web+=("$h")
done
echo "Web servers: ${web[*]}"
rm -r "$work_dir"
Run it:
bash L3-M4-ex3-mapfile.sh
Output:
Loaded 6 lines
Unique (4): cache01 db01 web01 web02
Web servers: web01 web02
Common mistakes#
- Writing
$servers[0]. You need braces:${servers[0]}. - Using
${arr[@]}without quotes, which breaks items that contain spaces. - Assuming indexes stay continuous after
unset. They do not, so loop with"${!arr[@]}"or re-pack the array. - Using commas:
arr=(a, b, c)makes the itemsa,b,andc.
L3-M5: Associative arrays#
An associative array (also called a map or dictionary) stores key to value pairs, like web -> 80. Create one with declare -A.
${map[key]} reads a value, "${!map[@]}" lists the keys, and ${#map[@]} is the count. Key order is not guaranteed, so sort the keys when the order matters.
Needs Bash 4 or newer. macOS ships Bash 3.2 by default; install a newer bash (for example with Homebrew) to use these.
Key syntax
declare -A port=([web]=80 [https]=443)
port[ssh]=22
echo "${port[web]}"
for k in "${!port[@]}"; do echo "$k=${port[$k]}"; done
[[ -n ${port[db]+set} ]] || echo "no db key"
Example 1 (Simple): A port lookup table#
Look up a value by its name, then list everything in sorted order. File: L3-M5-ex1-ports.sh
#!/bin/bash
declare -A port=([http]=80 [https]=443 [ssh]=22)
port[postgres]=5432
echo "SSH uses port ${port[ssh]}"
echo "We know ${#port[@]} services:"
for name in $(printf '%s\n' "${!port[@]}" | sort); do
echo " $name -> ${port[$name]}"
done
Run it:
bash L3-M5-ex1-ports.sh
Output:
SSH uses port 22
We know 4 services:
http -> 80
https -> 443
postgres -> 5432
ssh -> 22
Example 2 (Medium): Count things#
Use the value as a counter: how many times did each HTTP status code appear? File: L3-M5-ex2-counter.sh
#!/bin/bash
declare -A hits
codes=(200 404 200 500 200 404 301 200)
for c in "${codes[@]}"; do
(( hits[$c]++ ))
done
for c in $(printf '%s\n' "${!hits[@]}" | sort -n); do
printf "HTTP %s: %d\n" "$c" "${hits[$c]}"
done
Run it:
bash L3-M5-ex2-counter.sh
Output:
HTTP 200: 4
HTTP 301: 1
HTTP 404: 2
HTTP 500: 1
Example 3 (A bit harder): Load a config file with defaults#
Read key=value lines into a map, keep defaults for missing keys, and warn about unknown ones. File: L3-M5-ex3-config-map.sh
#!/bin/bash
work_dir=$(mktemp -d)
cat > "$work_dir/app.conf" <<'CONF'
# app settings
port=9090
env = prod
colour=blue
CONF
declare -A cfg=([port]=8080 [env]=dev [log_level]=info)
while IFS='=' read -r key value; do
[[ -z $key || $key == \#* ]] && continue
key=${key// /}; value=${value# }
if [[ -n ${cfg[$key]+set} ]]; then
cfg[$key]=$value
else
echo "warning: unknown setting '$key' ignored"
fi
done < "$work_dir/app.conf"
for k in env log_level port; do
echo "$k = ${cfg[$k]}"
done
rm -r "$work_dir"
Run it:
bash L3-M5-ex3-config-map.sh
Output:
warning: unknown setting 'colour' ignored
env = prod
log_level = info
port = 9090
Common mistakes#
- Forgetting
declare -A. Without it Bash creates a normal array and every text key is treated as index 0. - Expecting keys in insertion order. Sort them when order matters.
- Running on macOS's default Bash 3.2, which fails with
declare: -A: invalid option.
L3-M6: String manipulation#
Bash can slice and edit text inside ${ } without calling any outside program. This is called parameter expansion.
| Syntax | Result |
|---|---|
${#v} |
length |
${v:2:3} |
3 characters starting at index 2 |
${v#pat} / ${v##pat} |
remove shortest / longest match from the start |
${v%pat} / ${v%%pat} |
remove shortest / longest match from the end |
${v/old/new} / ${v//old/new} |
replace first / all |
${v^^} / ${v,,} |
UPPER / lower case |
${v:-x} |
use x if v is empty or unset |
${v:=x} |
as above, and also assign it |
${v:?msg} |
stop with an error if v is empty |
Memory trick: # is on the left of $ on a US keyboard, so it trims the left; % is on the right, so it trims the right.
Key syntax
path=/var/log/app/error.log
${path##*/} # error.log (file name)
${path%/*} # /var/log/app (folder)
file=error.log
${file%.*} # error (no extension)
${file##*.} # log (extension)
${name:-guest}
Example 1 (Simple): Length, case and slices#
Measure, change case and cut out pieces of a string. File: L3-M6-ex1-length-case.sh
#!/bin/bash
host="Web01.Prod.Example.com"
echo "Text: $host"
echo "Length: ${#host}"
echo "Lower: ${host,,}"
echo "Upper: ${host^^}"
echo "First 5: ${host:0:5}"
echo "From 6: ${host:6}"
echo "Last 3: ${host: -3}"
Run it:
bash L3-M6-ex1-length-case.sh
Output:
Text: Web01.Prod.Example.com
Length: 22
Lower: web01.prod.example.com
Upper: WEB01.PROD.EXAMPLE.COM
First 5: Web01
From 6: Prod.Example.com
Last 3: com
Example 2 (Medium): Take a path apart#
#, ##, % and %% peel pieces off the front or back of a path. File: L3-M6-ex2-path-parts.sh
#!/bin/bash
path="/var/log/nginx/access.log.1"
echo "Full path: $path"
echo "File name: ${path##*/}"
echo "Folder: ${path%/*}"
file=${path##*/}
echo "Without last .: ${file%.*}"
echo "Before first .: ${file%%.*}"
echo "Last extension: ${file##*.}"
echo "Drop /var: ${path#/var}"
Run it:
bash L3-M6-ex2-path-parts.sh
Output:
Full path: /var/log/nginx/access.log.1
File name: access.log.1
Folder: /var/log/nginx
Without last .: access.log
Before first .: access
Last extension: 1
Drop /var: /log/nginx/access.log.1
Example 3 (A bit harder): Replace, defaults and a file-name cleaner#
Search and replace, safe defaults, then clean a messy file name. File: L3-M6-ex3-replace-defaults.sh
#!/bin/bash
greeting="hello world, hello bash"
echo "Replace first: ${greeting/hello/hi}"
echo "Replace all: ${greeting//hello/hi}"
unset user_name
port=""
echo "User: ${user_name:-guest}"
echo "Port: ${port:-8080} (port is still '${port}')"
: "${port:=8080}"
echo "Port after := is '${port}'"
messy="My Report 2026 (final).TXT"
clean=${messy,,}
clean=${clean//[()]/}
clean=${clean// /_}
clean=${clean//__/_}
echo "Clean name: $clean"
Run it:
bash L3-M6-ex3-replace-defaults.sh
Output:
Replace first: hi world, hello bash
Replace all: hi world, hi bash
User: guest
Port: 8080 (port is still '')
Port after := is '8080'
Clean name: my_report_2026_final.txt
Common mistakes#
- Mixing up
#and%. Remember:#trims the front,%trims the back. - Using a regex in
${v//pat/}. These are glob patterns (*,?,[...]), not regular expressions. ${v: -3}needs the space before-3. Without it,${v:-3}means "default 3"!${v^^}and${v,,}need Bash 4+.
L3-M7: Here-documents and here-strings#
A here-document feeds several lines of text into a command, as if they came from a file. It starts with <<NAME and ends at a line that contains only NAME (often EOF).
<<EOFexpands$variablesand$(commands)inside the text.<<'EOF'(with quotes) keeps the text exactly as written, which is great for writing scripts or templates.<<-EOFstrips leading TAB characters so you can indent the block.
A here-string <<< "text" feeds a single string into a command.
Key syntax
cat <<EOF > app.conf
port=$port
EOF
cat <<'EOF' # no expansion
echo "$HOME"
EOF
read -r a b <<< "one two"
Example 1 (Simple): Generate a config file#
Variables are filled in as the file is written. File: L3-M7-ex1-heredoc-config.sh
#!/bin/bash
app="shop"
port=8443
env="prod"
work_dir=$(mktemp -d)
cat <<EOF > "$work_dir/$app.conf"
# generated config for $app
app_name=$app
listen_port=$port
environment=$env
log_file=/var/log/$app/$env.log
EOF
echo "Wrote $app.conf:"
cat "$work_dir/$app.conf"
rm -r "$work_dir"
Run it:
bash L3-M7-ex1-heredoc-config.sh
Output:
Wrote shop.conf:
# generated config for shop
app_name=shop
listen_port=8443
environment=prod
log_file=/var/log/shop/prod.log
Example 2 (Medium): Write a script with a script#
The quoted 'EOF' stops expansion, so $1 and $(date) reach the new file untouched. File: L3-M7-ex2-heredoc-quoted.sh
#!/bin/bash
work_dir=$(mktemp -d)
cat <<'EOF' > "$work_dir/hello.sh"
#!/bin/bash
echo "Hello, ${1:-stranger}!"
echo "You passed $# argument(s)."
EOF
echo "--- generated file ---"
cat "$work_dir/hello.sh"
echo "--- running it ---"
bash "$work_dir/hello.sh" "Pushpjeet"
rm -r "$work_dir"
Run it:
bash L3-M7-ex2-heredoc-quoted.sh
Output:
--- generated file ---
#!/bin/bash
echo "Hello, ${1:-stranger}!"
echo "You passed $# argument(s)."
--- running it ---
Hello, Pushpjeet!
You passed 1 argument(s).
Example 3 (A bit harder): Feed data straight into commands#
A here-doc feeds a loop, sort and awk, and a here-string splits a line into variables. File: L3-M7-ex3-heredoc-commands.sh
#!/bin/bash
echo "Disk report (largest first):"
sort -t: -k2 -nr <<EOF | while IFS=: read -r mount pct; do printf " %-6s %3s%%\n" "$mount" "$pct"; done
/:71
/home:45
/var:92
/tmp:8
EOF
total=$(awk '{ s += $2 } END { print s }' <<EOF
web01 120
web02 80
db01 300
EOF
)
echo "Total requests: $total"
record="db01 10.0.0.12 postgres"
read -r name ip role <<< "$record"
echo "name=$name ip=$ip role=$role"
Run it:
bash L3-M7-ex3-heredoc-commands.sh
Output:
Disk report (largest first):
/var 92%
/ 71%
/home 45%
/tmp 8%
Total requests: 500
name=db01 ip=10.0.0.12 role=postgres
Common mistakes#
- Indenting the closing
EOFwith spaces. It must be alone at the start of its line (or tab-indented with<<-). - Forgetting quotes on
'EOF'when writing scripts, so$1is expanded too early and comes out empty. - A space or invisible character after the closing
EOF, which leaves the here-doc unterminated.
L3-M8: Redirection and pipes#
Every command has three standard streams: stdin (0, input), stdout (1, normal output) and stderr (2, error messages).
| Syntax | Meaning |
|---|---|
> file |
send stdout to a file (overwrite) |
>> file |
append stdout to a file |
2> file |
send errors to a file |
> file 2>&1 |
send both to the same file (order matters!) |
&> file |
Bash shortcut for both |
> /dev/null |
throw output away (/dev/null is a black hole) |
< file |
read stdin from a file |
a \| b |
pipe: stdout of a becomes stdin of b |
tee copies its input to the screen AND to a file, which is useful for logs.
Key syntax
cmd > out.txt # overwrite
cmd >> out.txt # append
cmd 2> err.txt # errors only
cmd > all.txt 2>&1 # both
cmd > /dev/null 2>&1 # silence
cmd | grep x | sort # pipeline
cmd | tee log.txt # screen + file
Example 1 (Simple): Overwrite vs append#
> starts the file fresh; >> adds to the end. File: L3-M8-ex1-overwrite-append.sh
#!/bin/bash
work_dir=$(mktemp -d)
log="$work_dir/run.log"
echo "first run" > "$log"
echo "second run" > "$log"
echo "After two '>' writes:"
cat "$log"
echo "third run" >> "$log"
echo "fourth run" >> "$log"
echo "After two '>>' writes:"
cat "$log"
rm -r "$work_dir"
Run it:
bash L3-M8-ex1-overwrite-append.sh
Output:
After two '>' writes:
second run
After two '>>' writes:
second run
third run
fourth run
Example 2 (Medium): Separate output and errors#
Split stdout and stderr, combine them, silence them, and see why the order of 2>&1 matters. File: L3-M8-ex2-stderr.sh
#!/bin/bash
work_dir=$(mktemp -d)
cd "$work_dir" || exit 1
touch real.txt
ls real.txt missing.txt > out.txt 2> err.txt
echo "stdout file: $(cat out.txt)"
echo "stderr file has $(wc -l < err.txt) error line(s)"
ls real.txt missing.txt > both.txt 2>&1
echo "combined file has $(wc -l < both.txt) lines"
ls missing.txt > /dev/null 2>&1
echo "silenced: nothing printed, exit status $?"
echo "Wrong order (2>&1 > file) still shows the error on screen:"
# Deliberately wrong order, to demonstrate
# shellcheck disable=SC2069,SC2012
ls missing.txt 2>&1 > order.txt | sed 's/^/ screen: /'
cd / && rm -r "$work_dir"
Run it:
bash L3-M8-ex2-stderr.sh
Output:
stdout file: real.txt
stderr file has 1 error line(s)
combined file has 2 lines
silenced: nothing printed, exit status 2
Wrong order (2>&1 > file) still shows the error on screen:
screen: ls: cannot access 'missing.txt': No such file or directory
Note: The exact wording of
lserror messages differs a little between Linux and macOS.
Example 3 (A bit harder): A log-analysis pipeline#
Chain small tools: filter, cut, count, sort, then use tee to keep a copy. File: L3-M8-ex3-pipeline.sh
#!/bin/bash
work_dir=$(mktemp -d)
cat > "$work_dir/access.log" <<'LOG'
10.0.0.5 GET /index.html 200
10.0.0.9 GET /login 401
10.0.0.5 GET /cart 200
10.0.0.7 POST /login 401
10.0.0.9 GET /login 401
10.0.0.5 GET /index.html 200
10.0.0.9 POST /login 401
LOG
echo "Top IPs with failed logins (401):"
grep ' 401$' "$work_dir/access.log" \
| awk '{ print $1 }' \
| sort | uniq -c | sort -rn \
| tee "$work_dir/suspects.txt"
echo "Saved $(wc -l < "$work_dir/suspects.txt") lines to suspects.txt"
rm -r "$work_dir"
Run it:
bash L3-M8-ex3-pipeline.sh
Output:
Top IPs with failed logins (401):
3 10.0.0.9
1 10.0.0.7
Saved 2 lines to suspects.txt
Common mistakes#
- Writing
cmd 2>&1 > fileand expecting both in the file. Redirections are read left to right: put> filefirst. - Using
>when you meant>>, which wipes the log. sort file > fileempties the file before sort reads it. Write to a temp file, then move it.- Piping a command's errors:
|carries only stdout. Use2>&1 |or|&to pipe errors too.
L3-M9: Exit codes in your own scripts#
exit N stops the whole script immediately and reports status N to whoever ran it: you, another script, cron or a CI pipeline.
Conventions: 0 = success, 1 = general error, 2 = wrong usage or bad arguments. Pick distinct codes for different failures so callers can tell them apart. Without an explicit exit, a script returns the status of its last command.
Check a script's status after it runs with echo $?.
Key syntax
exit 0 # success
exit 1 # general failure
exit 2 # usage / bad input
bash my.sh; echo "status: $?"
Example 1 (Simple): Exit with an error code#
The script stops at exit, so the last echo never runs. File: L3-M9-ex1-exit.sh
#!/bin/bash
echo "Starting backup..."
echo "Backup folder not found."
exit 2
# shellcheck disable=SC2317
echo "This line never runs."
Run it:
bash L3-M9-ex1-exit.sh
Output (exits with status 2; check with echo $?):
Starting backup...
Backup folder not found.
Example 2 (Medium): Validate a number#
Exit early when the input is not a whole number. File: L3-M9-ex2-exit.sh
#!/bin/bash
read -r -p "How many instances? " count
if [[ ! "$count" =~ ^[0-9]+$ ]]; then
echo "Error: '$count' is not a number."
exit 1
fi
echo "Launching $count instances."
exit 0
Run it:
printf 'abc\n' | bash L3-M9-ex2-exit.sh
Output (exits with status 1; check with echo $?):
Error: 'abc' is not a number.
Example 3 (A bit harder): Check a file and report status#
Return different exit codes for different problems. File: L3-M9-ex3-exit.sh
#!/bin/bash
work_dir=$(mktemp -d)
file="$work_dir/deploy.conf"
echo "env=prod" > "$file"
if [ ! -f "$file" ]; then
echo "Missing file."
rm -r "$work_dir"
exit 1
fi
if [ ! -s "$file" ]; then
echo "File is empty."
rm -r "$work_dir"
exit 2
fi
echo "Config OK: $(cat "$file")"
rm -r "$work_dir"
exit 0
Run it:
bash L3-M9-ex3-exit.sh
Output:
Config OK: env=prod
Common mistakes#
- Using
exitinside a function when you meantreturn.exitends the whole script. - Exiting with a code above 255, which wraps around (256 becomes 0, so it looks like success!).
- Forgetting to clean up temp files before exiting early. Level 4's
trapsolves this.
Level 3 practice exercises#
Try each one yourself before opening the solution.
Exercise 1#
Write a function max that prints the larger of two numbers. Capture and print max 12 30 and max 9 4.
Hint: Return data with echo and capture it with $( ).
Show solution
File: L3-P1-solution.sh
#!/bin/bash
max() {
if (( $1 > $2 )); then echo "$1"; else echo "$2"; fi
}
echo "max 12 30 -> $(max 12 30)"
echo "max 9 4 -> $(max 9 4)"
Run it:
bash L3-P1-solution.sh
Output:
max 12 30 -> 30
max 9 4 -> 9
Exercise 2#
Write a script that prints how many arguments it got and lists each with its position. Run it as bash solution.sh alpha "beta gamma" delta.
Hint: Loop over "$@" and keep a counter.
Show solution
File: L3-P2-solution.sh
#!/bin/bash
echo "You gave $# argument(s)."
i=1
for arg in "$@"; do
echo " $i: $arg"
(( i++ ))
done
Run it:
bash L3-P2-solution.sh alpha 'beta gamma' delta
Output:
You gave 3 argument(s).
1: alpha
2: beta gamma
3: delta
Exercise 3#
Store four countries and their capitals in an associative array and print them sorted by country.
Hint: Sort the keys with printf | sort, then look each one up.
Show solution
File: L3-P3-solution.sh
#!/bin/bash
declare -A capital=([India]="New Delhi" [Kenya]="Nairobi" [Japan]="Tokyo" [Brazil]="Brasilia")
while IFS= read -r country; do
echo "$country: ${capital[$country]}"
done < <(printf '%s\n' "${!capital[@]}" | sort)
Run it:
bash L3-P3-solution.sh
Output:
Brazil: Brasilia
India: New Delhi
Japan: Tokyo
Kenya: Nairobi
Exercise 4#
For p=/home/student/projects/site/index.backup.html, print the folder, the file name, the extension and the name without its LAST extension, using only parameter expansion.
Hint: ##/ %/ ##. %.
Show solution
File: L3-P4-solution.sh
#!/bin/bash
p=/home/student/projects/site/index.backup.html
file=${p##*/}
echo "Folder: ${p%/*}"
echo "File: $file"
echo "Extension: ${file##*.}"
echo "Name: ${file%.*}"
Run it:
bash L3-P4-solution.sh
Output:
Folder: /home/student/projects/site
File: index.backup.html
Extension: html
Name: index.backup
Exercise 5#
Write a function log_error that prints ERROR: <message> to stderr. Call it twice and also print one normal line. Run the script so stderr goes to a temp file, then show what the file contains.
Hint: >&2 sends output to stderr; 2> file captures it.
Show solution
File: L3-P5-solution.sh
#!/bin/bash
log_error() {
echo "ERROR: $*" >&2
}
work_dir=$(mktemp -d)
{
echo "normal output line"
log_error "disk is full"
log_error "backup skipped"
} 2> "$work_dir/errors.txt"
echo "errors.txt contains:"
cat "$work_dir/errors.txt"
rm -r "$work_dir"
Run it:
bash L3-P5-solution.sh
Output:
normal output line
errors.txt contains:
ERROR: disk is full
ERROR: backup skipped
Level 3: check yourself#
Five multiple-choice questions cover Level 3. Each answer comes with an explanation of why every option is right or wrong, and you earn XP as you go.
Level 4: Pro#
Write scripts you can trust in production
This is where hobby scripts become professional tools. You will make scripts fail safely, clean up after themselves, accept flags, check their input, log what they do, run safely twice, and be easy to debug and schedule.
The last module puts it all together in real DevOps-style scripts. Take it slowly, because each idea here prevents a real outage.
L4-M1: Strict mode: set -euo pipefail (and its gotchas)#
By default Bash keeps going after errors, which can turn a small problem into a disaster (imagine cd /missing failing and the next line being rm -rf *). Strict mode makes failures loud:
set -e: exit as soon as a command fails.set -u: treat an unset variable as an error, which catches typos.set -o pipefail: a pipeline fails if ANY part fails, not just the last command.
Gotchas: set -e is ignored inside if conditions and on the left side of && and ||. ((count++)) returns status 1 when count was 0, which kills the script. grep with no match returns 1. local x=$(cmd) hides cmd's failure. Strict mode is a safety net, not a substitute for checking important commands yourself.
Key syntax
#!/bin/bash
set -euo pipefail
cmd || true # allow this one to fail
(( count += 1 )) # safe increment (or count=$((count + 1)))
if grep -q x f; then ... # failures inside if are allowed
: "${1:?usage: $0 NAME}" # required argument
Example 1 (Simple): set -u catches a typo#
A misspelled variable name stops the script instead of silently using an empty value. File: L4-M1-ex1-set-u.sh
#!/bin/bash
set -u
server="web01"
echo "Deploying to $server"
# Deliberate typo to show set -u in action
# shellcheck disable=SC2154
echo "Restarting $sevrer"
# shellcheck disable=SC2317
echo "This line never runs"
Run it:
bash L4-M1-ex1-set-u.sh
Output (exits with status 1; check with echo $?):
Deploying to web01
L4-M1-ex1-set-u.sh: line 8: sevrer: unbound variable
Note: The script stops with exit status 1 at the typo. Without
set -uit would have printedRestartingwith an empty name and carried on.
Example 2 (Medium): What -e and pipefail change#
Each test runs in a ( subshell ), so the main script survives and can report what happened. File: L4-M1-ex2-e-pipefail.sh
#!/bin/bash
echo "1) Without set -e:"
( false; echo " still running after false" )
echo "2) With set -e:"
( set -e; false; echo " never printed" )
echo " the subshell stopped at false, status $?"
echo "3) set -e alone misses a failure inside a pipe:"
( set -e; false | sort; echo " 'false | sort' did not stop anything" )
echo "4) set -e plus pipefail catches it:"
( set -eo pipefail; false | sort; echo " never printed" )
echo " stopped at the pipe, status $?"
Run it:
bash L4-M1-ex2-e-pipefail.sh
Output:
1) Without set -e:
still running after false
2) With set -e:
the subshell stopped at false, status 1
3) set -e alone misses a failure inside a pipe:
'false | sort' did not stop anything
4) set -e plus pipefail catches it:
stopped at the pipe, status 1
Example 3 (A bit harder): Gotchas and their fixes#
Three traps that surprise strict-mode users, each paired with the safe pattern. File: L4-M1-ex3-gotchas.sh
#!/bin/bash
# Each experiment runs in its own ( subshell ) with strict mode switched on,
# so this main script survives and can report the result.
echo "Gotcha 1: ((count++)) when count is 0"
( set -e; count=0; ((count++)); echo " never printed" )
echo " died with status $?: ((0)) counts as false"
( set -e; count=0; (( count += 1 )); echo " fix: (( count += 1 )) -> count=$count" )
echo "Gotcha 2: grep that finds nothing exits 1"
( set -eo pipefail; n=$(printf 'INFO ok\n' | grep -c ERROR); echo " never printed $n" )
echo " died with status $?"
( set -eo pipefail; n=$(printf 'INFO ok\n' | grep -c ERROR || true); echo " fix: add || true -> n=$n" )
echo "Gotcha 3: set -e is switched off on the left of || (and inside if)"
( set -e; false; echo " printed anyway! set -e was ignored" ) || true
echo "Gotcha 4: local x=\$(cmd) hides the failure"
( set -e
# Deliberately written the risky way
# shellcheck disable=SC2155
f() { local out=$(false); echo " failure hidden [$out], function kept going"; }
f )
( set -e
g() { local out; out=$(false); echo " never printed [$out]"; }
g )
echo " fix: 'local out; out=\$(cmd)' -> the failure stopped it (status $?)"
Run it:
bash L4-M1-ex3-gotchas.sh
Output:
Gotcha 1: ((count++)) when count is 0
died with status 1: ((0)) counts as false
fix: (( count += 1 )) -> count=1
Gotcha 2: grep that finds nothing exits 1
died with status 1
fix: add || true -> n=0
Gotcha 3: set -e is switched off on the left of || (and inside if)
printed anyway! set -e was ignored
Gotcha 4: local x=$(cmd) hides the failure
failure hidden [], function kept going
fix: 'local out; out=$(cmd)' -> the failure stopped it (status 1)
Common mistakes#
- Adding
set -eand assuming every error is now handled. Read the gotchas above. - Putting
set -euo pipefailon line 1, before the shebang. The shebang must be first. - Referencing optional variables under
set -u. Use${VAR:-}to allow empty values. - Copy-pasting strict mode into scripts you
sourceinto your interactive shell. A failure will close your terminal.
L4-M2: trap: clean up no matter what#
trap 'commands' SIGNALS runs your commands when the script receives a signal or event. The most useful one is EXIT, which runs when the script ends for any reason: success, error, exit, or Ctrl+C.
A signal is a message the system sends to a process: INT is Ctrl+C, TERM is a polite "please stop" (what kill sends by default). ERR is a Bash event that fires when a command fails.
Standard pattern: create temp files, register a cleanup function with trap cleanup EXIT, and stop worrying about leftovers.
Key syntax
cleanup() { rm -rf "$work_dir"; }
trap cleanup EXIT # always runs at the end
trap 'echo interrupted; exit 130' INT TERM
trap 'echo "failed at line $LINENO"' ERR
trap - EXIT # remove a trap
Example 1 (Simple): Always remove temp files#
The EXIT trap runs after the last line, so cleanup is automatic. File: L4-M2-ex1-trap-exit.sh
#!/bin/bash
work_dir=$(mktemp -d)
cleanup() {
rm -rf "$work_dir"
echo "cleanup: temp folder removed"
}
trap cleanup EXIT
echo "data" > "$work_dir/report.txt"
echo "Working with $(wc -l < "$work_dir/report.txt") file line(s)..."
echo "Main work finished."
Run it:
bash L4-M2-ex1-trap-exit.sh
Output:
Working with 1 file line(s)...
Main work finished.
cleanup: temp folder removed
Example 2 (Medium): Cleanup runs even when the script fails#
Strict mode stops the script at the failing command, but the EXIT trap still runs, and the exit status is preserved. File: L4-M2-ex2-trap-on-error.sh
#!/bin/bash
set -euo pipefail
work_dir=$(mktemp -d)
cleanup() {
local status=$?
rm -rf "$work_dir"
echo "cleanup: removed temp folder (script exit status: $status)"
}
trap cleanup EXIT
echo "Step 1: downloading (simulated)"
touch "$work_dir/package.tar.gz"
echo "Step 2: verifying checksum"
grep -q "expected-hash" "$work_dir/package.tar.gz" # fails: file is empty
echo "Step 3: never reached"
Run it:
bash L4-M2-ex2-trap-on-error.sh
Output (exits with status 1; check with echo $?):
Step 1: downloading (simulated)
Step 2: verifying checksum
cleanup: removed temp folder (script exit status: 1)
Example 3 (A bit harder): Handle Ctrl+C gracefully#
We simulate Ctrl+C by sending INT to ourselves at step 3. The INT trap reports, then the EXIT trap cleans up. File: L4-M2-ex3-trap-int.sh
#!/bin/bash
work_dir=$(mktemp -d)
step=0
cleanup() {
rm -rf "$work_dir"
echo "[EXIT] temp folder removed"
}
on_interrupt() {
echo "[INT] Ctrl+C received during step $step. Stopping safely."
exit 130
}
trap cleanup EXIT
trap on_interrupt INT TERM
for step in 1 2 3 4 5; do
echo "Working on step $step..."
touch "$work_dir/part$step"
if (( step == 3 )); then
kill -INT $$ # pretend the user pressed Ctrl+C
fi
done
echo "All steps finished."
Run it:
bash L4-M2-ex3-trap-int.sh
Output (exits with status 130; check with echo $?):
Working on step 1...
Working on step 2...
Working on step 3...
[INT] Ctrl+C received during step 3. Stopping safely.
[EXIT] temp folder removed
Note: Exit status 130 (128 + signal 2) is the convention for "stopped by Ctrl+C".
Common mistakes#
- Using double quotes in the trap string:
trap "rm $file" EXITexpands$fileNOW. Use single quotes or a function. - Calling
exitwithout a code in an INT handler, so the script reports success. Useexit 130. - Setting a second
trap ... EXITlater in the script. It REPLACES the first one; put all cleanup in one function.
L4-M3: getopts: proper command-line flags#
getopts is the built-in way to read short flags like -v or -n web01.
The option string lists your letters; a letter followed by : takes a value, which arrives in $OPTARG. Starting the string with : lets you print your own error messages.
After the loop, shift $((OPTIND - 1)) removes the processed flags so "$@" holds only the remaining arguments (like file names).
getopts handles short options only. For --long options use a while/case/shift loop (Level 3) or the external getopt tool.
Key syntax
while getopts ":vn:h" opt; do
case $opt in
v) verbose=1 ;;
n) name=$OPTARG ;;
h) usage; exit 0 ;;
:) echo "-$OPTARG needs a value" >&2; exit 2 ;;
\?) echo "unknown -$OPTARG" >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
Example 1 (Simple): Two simple flags#
-v is an on/off switch and -n takes a value. File: L4-M3-ex1-getopts-basic.sh
#!/bin/bash
verbose=0
name="world"
while getopts "vn:" opt; do
case $opt in
v) verbose=1 ;;
n) name=$OPTARG ;;
*) exit 2 ;;
esac
done
echo "Hello, $name!"
if (( verbose )); then
echo "(verbose mode is on)"
fi
Run it:
bash L4-M3-ex1-getopts-basic.sh -v -n web01
Output:
Hello, web01!
(verbose mode is on)
Example 2 (Medium): Flags plus leftover arguments#
A usage message, defaults, and the remaining file names after shift $((OPTIND - 1)). File: L4-M3-ex2-getopts-files.sh
#!/bin/bash
usage() {
echo "Usage: $0 [-e env] [-c count] [-h] file..."
}
env="dev"
count=1
while getopts ":e:c:h" opt; do
case $opt in
e) env=$OPTARG ;;
c) count=$OPTARG ;;
h) usage; exit 0 ;;
:) echo "Error: -$OPTARG needs a value" >&2; usage >&2; exit 2 ;;
\?) echo "Error: unknown option -$OPTARG" >&2; usage >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
echo "env=$env count=$count"
echo "Files to deploy ($#):"
for f in "$@"; do echo " - $f"; done
Run it:
bash L4-M3-ex2-getopts-files.sh -e prod -c 3 app.tar.gz config.yml
Output:
env=prod count=3
Files to deploy (2):
- app.tar.gz
- config.yml
Example 3 (A bit harder): A robust parser, tested four ways#
Parsing lives in a function with local OPTIND, so we can call it with several argument sets and see each result. File: L4-M3-ex3-getopts-robust.sh
#!/bin/bash
usage() { echo "usage: deploy -e dev|test|prod [-c N] [-d]"; }
deploy() {
local OPTIND opt env="" count=1 dry=0
while getopts ":e:c:d" opt; do
case $opt in
e) env=$OPTARG ;;
c) count=$OPTARG ;;
d) dry=1 ;;
:) echo " error: -$OPTARG needs a value"; return 2 ;;
\?) echo " error: unknown option -$OPTARG"; return 2 ;;
esac
done
case $env in
dev|test|prod) ;;
"") echo " error: -e is required"; usage | sed 's/^/ /'; return 2 ;;
*) echo " error: '$env' is not dev, test or prod"; return 2 ;;
esac
if [[ ! $count =~ ^[1-9][0-9]*$ ]]; then
echo " error: -c must be a positive whole number, got '$count'"; return 2
fi
echo " OK: deploying $count instance(s) to $env$( (( dry )) && echo ' (dry run)')"
}
for args in "-e prod -c 3" "-e test -d" "-e staging" "-c" "-e dev -c two" "-x"; do
echo "deploy $args"
# Word splitting is intended here: each string is a list of flags
# shellcheck disable=SC2086
deploy $args || echo " -> exit status $?"
done
Run it:
bash L4-M3-ex3-getopts-robust.sh
Output:
deploy -e prod -c 3
OK: deploying 3 instance(s) to prod
deploy -e test -d
OK: deploying 1 instance(s) to test (dry run)
deploy -e staging
error: 'staging' is not dev, test or prod
-> exit status 2
deploy -c
error: -c needs a value
-> exit status 2
deploy -e dev -c two
error: -c must be a positive whole number, got 'two'
-> exit status 2
deploy -x
error: unknown option -x
-> exit status 2
Common mistakes#
- Forgetting the
:after a letter that needs a value, so$OPTARGis empty. - Forgetting
shift $((OPTIND - 1)), so the flags are still mixed in with your file arguments. - Reusing getopts in a function without
local OPTIND. The second call silently parses nothing. - Expecting
getoptsto understand--help. It only knows short options.
L4-M4: Input validation#
Never trust input, whether it comes from users, files, arguments or other programs. Check it before you act on it, and fail early with a clear message and a non-zero exit code.
Common checks: not empty (-z), a whole number (=~ ^[0-9]+$), within a range, one of an allowed list (case), a file that exists and is readable, a safe name format.
A tiny die function keeps error handling consistent: print to stderr, exit with a code.
Key syntax
die() { echo "error: $*" >&2; exit 2; }
[[ -n $name ]] || die "name is required"
[[ $port =~ ^[0-9]+$ ]] || die "port must be a number"
(( port >= 1 && port <= 65535 )) || die "port out of range"
[[ -r $file ]] || die "cannot read $file"
Example 1 (Simple): Check a config file before using it#
Combine several [ ] checks: exists, readable, not empty. File: L4-M4-ex1-config-check.sh
#!/bin/bash
work_dir=$(mktemp -d)
config="$work_dir/app.conf"
echo "port=8080" > "$config"
if [ ! -f "$config" ]; then
echo "Config file is missing."
elif [ ! -r "$config" ]; then
echo "Config file cannot be read."
elif [ ! -s "$config" ]; then
echo "Config file is empty."
else
echo "Config file is OK: $(cat "$config")"
fi
rm -r "$work_dir"
Run it:
bash L4-M4-ex1-config-check.sh
Output:
Config file is OK: port=8080
Example 2 (Medium): Ask again until the input is valid#
Up to three tries to type a valid port number. Each mistake gets a specific message. File: L4-M4-ex2-validate-port.sh
#!/bin/bash
valid_port() {
local p=$1
if [[ -z $p ]]; then echo " empty input"; return 1; fi
if [[ ! $p =~ ^[0-9]+$ ]]; then echo " '$p' is not a whole number"; return 1; fi
if (( 10#$p < 1 || 10#$p > 65535 )); then echo " $p is out of range (1-65535)"; return 1; fi
return 0
}
port=""
for try in 1 2 3; do
read -r -p "Port (try $try/3): " answer
if valid_port "$answer"; then
port=$answer
break
fi
done
if [[ -z $port ]]; then
echo "No valid port after 3 tries. Giving up."
exit 2
fi
echo "Port accepted: $port"
Run it:
printf 'abc\n70000\n8080\n' | bash L4-M4-ex2-validate-port.sh
Output:
'abc' is not a whole number
70000 is out of range (1-65535)
Port accepted: 8080
Example 3 (A bit harder): Validate command-line arguments#
A die helper, an allowed-values check and an IPv4 checker with a clear reason. Here the IP is wrong, so the script stops with status 2. File: L4-M4-ex3-validate-args.sh
#!/bin/bash
die() { echo "error: $*" >&2; exit 2; }
valid_ipv4() {
local ip=$1 part
[[ $ip =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1
for part in "${BASH_REMATCH[@]:1}"; do
(( 10#$part <= 255 )) || return 1
done
}
(( $# == 2 )) || die "usage: $0 ENV IP (example: $0 prod 10.0.0.5)"
env=$1
ip=$2
case $env in
dev|test|prod) echo "env ok: $env" ;;
*) die "ENV must be dev, test or prod (got '$env')" ;;
esac
for sample in 10.0.0.5 192.168.1.256 10.0.300.5 8.8.8.8 1.2.3; do
if valid_ipv4 "$sample"; then r="valid"; else r="invalid"; fi
printf " self-test %-15s %s\n" "$sample" "$r"
done
valid_ipv4 "$ip" || die "'$ip' is not a valid IPv4 address (four numbers 0-255)"
echo "Connecting to $ip in $env"
Run it:
bash L4-M4-ex3-validate-args.sh prod 10.0.300.5
Output (exits with status 2; check with echo $?):
env ok: prod
self-test 10.0.0.5 valid
self-test 192.168.1.256 invalid
self-test 10.0.300.5 invalid
self-test 8.8.8.8 valid
self-test 1.2.3 invalid
error: '10.0.300.5' is not a valid IPv4 address (four numbers 0-255)
Common mistakes#
- Validating with
-eqbefore checking that the value is a number. You get a messyinteger expression expectederror. - Forgetting that numbers with leading zeros (
08) are treated as octal in(( )). Force base 10 with10#$n. - Printing errors to stdout. Send them to stderr (
>&2) so they do not mix with real output. - Building commands from raw user input (
eval "$input"). That is a security hole; validate input against an allowed list.
L4-M5: A logging function with timestamps#
When a script runs at 3 a.m. from cron, its log is the only witness. A small log function gives every message a timestamp and a level (INFO, WARN, ERROR), and can write to the screen, to a file, or both.
Send logs to stderr so they never mix with data your script outputs on stdout.
date '+%Y-%m-%d %H:%M:%S' gives a sortable timestamp. Bash 4.2+ can do it without calling date: printf '%(%F %T)T'.
Key syntax
log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "${*:2}" >&2; }
log INFO "backup started"
log ERROR "disk full"
cmd 2>&1 | tee -a "$LOG_FILE" # screen + file
Example 1 (Simple): A timestamped log line#
One function, consistent output. File: L4-M5-ex1-log-basic.sh
#!/bin/bash
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $*"
}
log "Backup started"
log "Copied 42 files"
log "Backup finished"
Run it:
bash L4-M5-ex1-log-basic.sh
Sample output (yours will differ: timestamps show the current date and time):
2026-10-08 18:55:32 Backup started
2026-10-08 18:55:32 Copied 42 files
2026-10-08 18:55:32 Backup finished
Example 2 (Medium): Levels and a minimum level#
INFO, WARN and ERROR, with LOG_LEVEL controlling how chatty the script is. Logs go to stderr. File: L4-M5-ex2-log-levels.sh
#!/bin/bash
LOG_LEVEL=${LOG_LEVEL:-INFO}
declare -A RANK=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3)
log() {
local level=$1; shift
(( RANK[$level] >= RANK[$LOG_LEVEL] )) || return 0
printf '%(%Y-%m-%d %H:%M:%S)T [%-5s] %s\n' -1 "$level" "$*" >&2
}
echo "--- LOG_LEVEL=$LOG_LEVEL ---"
log DEBUG "connecting to db01"
log INFO "service started"
log WARN "disk at 82%"
log ERROR "backup failed"
LOG_LEVEL=WARN
echo "--- LOG_LEVEL=$LOG_LEVEL ---"
log INFO "this is hidden"
log WARN "disk at 85%"
log ERROR "backup failed again"
Run it:
bash L4-M5-ex2-log-levels.sh
Sample output (yours will differ: timestamps show the current date and time):
--- LOG_LEVEL=INFO ---
2026-10-08 18:55:32 [INFO ] service started
2026-10-08 18:55:32 [WARN ] disk at 82%
2026-10-08 18:55:32 [ERROR] backup failed
--- LOG_LEVEL=WARN ---
2026-10-08 18:55:32 [WARN ] disk at 85%
2026-10-08 18:55:32 [ERROR] backup failed again
Example 3 (A bit harder): Log to screen and file#
Every line goes to the terminal and to a log file. The script name and process ID make lines from different runs easy to tell apart. File: L4-M5-ex3-log-tee.sh
#!/bin/bash
work_dir=$(mktemp -d)
LOG_FILE="$work_dir/app.log"
SCRIPT=${0##*/}
log() {
local line
line="$(date '+%F %T') ${SCRIPT}[$$] [$1] ${*:2}"
echo "$line" | tee -a "$LOG_FILE" >&2
}
log INFO "job started"
log WARN "retrying upload (1/3)"
log INFO "job finished"
echo "Log file now has $(wc -l < "$LOG_FILE") lines. Last line:"
tail -n 1 "$LOG_FILE"
rm -r "$work_dir"
Run it:
bash L4-M5-ex3-log-tee.sh
Sample output (yours will differ: timestamps and the process ID [PID] change every run):
2026-10-08 18:55:32 L4-M5-ex3-log-tee.sh[2327292] [INFO] job started
2026-10-08 18:55:32 L4-M5-ex3-log-tee.sh[2327292] [WARN] retrying upload (1/3)
2026-10-08 18:55:32 L4-M5-ex3-log-tee.sh[2327292] [INFO] job finished
Log file now has 3 lines. Last line:
2026-10-08 18:55:32 L4-M5-ex3-log-tee.sh[2327292] [INFO] job finished
Common mistakes#
- Logging to stdout in a script whose output is captured or piped, which corrupts the data.
- Timestamps without a date, or in a non-sortable format (
10/08/26). UseYYYY-MM-DD HH:MM:SS. - Logging secrets (passwords, tokens). Mask them before you log.
- Letting log files grow forever. Rotate them (see the DevOps module).
L4-M6: mktemp: safe temporary files#
Never hard-code temp names like /tmp/data.txt. Two copies of the script would clash, and another user could create that file first to trick your script.
mktemp creates a file with a unique random name that only you can read (permissions 600); mktemp -d makes a private folder (700). Always pair it with trap ... EXIT cleanup.
For an atomic update, write to a temp file in the same folder and then mv it into place. Readers see either the old file or the new one, never half of it.
Key syntax
tmp=$(mktemp) # /tmp/tmp.Xk3j9aQ
dir=$(mktemp -d) # private folder
f=$(mktemp /tmp/backup.XXXXXX) # your own prefix
trap 'rm -rf "$tmp" "$dir"' EXIT
mv "$tmp" final.conf # atomic replace
Example 1 (Simple): Create and use a temp file#
mktemp returns the new file's name; we use it, then remove it. File: L4-M6-ex1-mktemp-file.sh
#!/bin/bash
tmp=$(mktemp)
echo "Temp file: $tmp"
echo "line 1" > "$tmp"
echo "line 2" >> "$tmp"
echo "It holds $(wc -l < "$tmp") lines"
rm -f "$tmp"
[[ -e $tmp ]] || echo "Removed."
Run it:
bash L4-M6-ex1-mktemp-file.sh
Sample output (yours will differ: the random file name changes every run):
Temp file: /tmp/tmp.7qvfek24vz
It holds 2 lines
Removed.
Example 2 (Medium): Private by default#
Temp files and folders are created so only you can use them. We check the permissions and clean up with a trap. File: L4-M6-ex2-mktemp-private.sh
#!/bin/bash
perms() { stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"; }
dir=$(mktemp -d "${TMPDIR:-/tmp}/backup.XXXXXX")
file=$(mktemp "$dir/part.XXXXXX")
trap 'rm -rf "$dir"' EXIT
echo "Folder permissions: $(perms "$dir") (only you can enter)"
echo "File permissions: $(perms "$file") (only you can read/write)"
name=${dir##*/}
echo "Folder name starts with 'backup.' and has ${#name} characters"
Run it:
bash L4-M6-ex2-mktemp-private.sh
Output:
Folder permissions: 700 (only you can enter)
File permissions: 600 (only you can read/write)
Folder name starts with 'backup.' and has 13 characters
Example 3 (A bit harder): Atomic config update#
Build the new file in a temp file next to the real one, check it, then mv it into place in one step. File: L4-M6-ex3-atomic-update.sh
#!/bin/bash
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT
conf="$work_dir/app.conf"
printf 'port=8080\nworkers=2\n' > "$conf"
update_setting() {
local key=$1 value=$2 tmp
tmp=$(mktemp "$conf.XXXXXX")
sed "s/^$key=.*/$key=$value/" "$conf" > "$tmp"
if grep -q "^$key=$value$" "$tmp"; then
mv "$tmp" "$conf"
echo "updated $key -> $value"
else
rm -f "$tmp"
echo "key '$key' not found; original left untouched"
return 1
fi
}
update_setting workers 8
update_setting colour blue || true
echo "Final config:"
cat "$conf"
leftovers=("$work_dir"/app.conf.*)
if [[ -e ${leftovers[0]} ]]; then
echo "Leftover temp files: ${#leftovers[@]}"
else
echo "No leftover temp files"
fi
Run it:
bash L4-M6-ex3-atomic-update.sh
Output:
updated workers -> 8
key 'colour' not found; original left untouched
Final config:
port=8080
workers=8
No leftover temp files
Common mistakes#
- Using fixed names like
/tmp/out.txt. - Creating temp files without a trap, leaving junk behind after every failure.
- Creating the temp file on a different filesystem from the target, so
mvis a slow copy instead of an atomic rename. Create it in the same folder.
L4-M7: Idempotent scripts#
Idempotent means running it once or ten times gives the same end result. That makes scripts safe to re-run after a failure, and safe to run from cron or configuration-management tools.
Techniques: mkdir -p (no error if it already exists), ln -sfn, "check before you change" (only add a line if it is missing), marker files that record finished steps, and a lock so two copies never run at once (mkdir is atomic, and so is flock).
Key syntax
mkdir -p /opt/app/logs
grep -qxF "$line" "$f" || echo "$line" >> "$f"
[[ -f step1.done ]] || { do_step1 && touch step1.done; }
mkdir "$lock" 2>/dev/null || { echo "already running"; exit 0; }
Example 1 (Simple): Safe to run twice#
The same setup function runs twice. The second run changes nothing and reports that. File: L4-M7-ex1-idem-setup.sh
#!/bin/bash
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT
cd "$work_dir" || exit 1
setup() {
mkdir -p app/logs app/config
if [[ -f app/config/app.conf ]]; then
echo " app.conf exists, leaving it alone"
else
echo "port=8080" > app/config/app.conf
echo " created app.conf"
fi
ln -sfn app/config/app.conf current.conf
echo " folders: $(find app -type d | sort | tr '\n' ' ')"
}
echo "Run 1:"; setup
echo "Run 2:"; setup
Run it:
bash L4-M7-ex1-idem-setup.sh
Output:
Run 1:
created app.conf
folders: app app/config app/logs
Run 2:
app.conf exists, leaving it alone
folders: app app/config app/logs
Example 2 (Medium): Add a line only once#
Appending with >> blindly creates duplicates; checking first does not. File: L4-M7-ex2-ensure-line.sh
#!/bin/bash
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT
naive="$work_dir/naive.conf"
safe="$work_dir/safe.conf"
ensure_line() {
local line=$1 file=$2
if grep -qxF -- "$line" "$file" 2>/dev/null; then
echo " already present: $line"
else
echo "$line" >> "$file"
echo " added: $line"
fi
}
for run in 1 2 3; do
echo "Run $run:"
echo "export EDITOR=vim" >> "$naive"
ensure_line "export EDITOR=vim" "$safe"
done
echo "naive.conf has $(wc -l < "$naive") lines, safe.conf has $(wc -l < "$safe") line"
Run it:
bash L4-M7-ex2-ensure-line.sh
Output:
Run 1:
added: export EDITOR=vim
Run 2:
already present: export EDITOR=vim
Run 3:
already present: export EDITOR=vim
naive.conf has 3 lines, safe.conf has 1 line
Example 3 (A bit harder): Resume after failure, with a lock#
Marker files remember finished steps, so a re-run continues where it stopped. A lock folder stops two copies running at once. File: L4-M7-ex3-resume-lock.sh
#!/bin/bash
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT
state="$work_dir/state"
lock="$work_dir/deploy.lock"
mkdir -p "$state"
network_ok=no # the first run will fail at the "download" step
step() {
local name=$1; shift
if [[ -f $state/$name.done ]]; then
echo " skip $name (done earlier)"
return 0
fi
if "$@"; then
touch "$state/$name.done"
echo " done $name"
else
echo " FAIL $name"
return 1
fi
}
prepare() { mkdir -p "$work_dir/release"; }
download() { [[ $network_ok == yes ]]; }
install() { touch "$work_dir/release/app"; }
deploy() {
if ! mkdir "$lock" 2>/dev/null; then
echo " another deploy is running, exiting"
return 0
fi
step prepare prepare && step download download && step install install
local status=$?
rmdir "$lock"
return $status
}
echo "Run 1:"; deploy || echo " run 1 stopped (status $?)"
network_ok=yes
echo "Run 2 while another copy holds the lock:"; mkdir "$lock"; deploy; rmdir "$lock"
echo "Run 3:"; deploy
echo "Run 4:"; deploy
Run it:
bash L4-M7-ex3-resume-lock.sh
Output:
Run 1:
done prepare
FAIL download
run 1 stopped (status 1)
Run 2 while another copy holds the lock:
another deploy is running, exiting
Run 3:
skip prepare (done earlier)
done download
done install
Run 4:
skip prepare (done earlier)
skip download (done earlier)
skip install (done earlier)
Common mistakes#
- Using
mkdirwithout-pin setup scripts, so the second run fails with "File exists". - Appending config lines with
>>on every run. - Using a PID file without handling stale locks after a crash. Prefer
flock, or remove the lock in a trap. - Treating "it worked once" as tested. Always run your script twice.
L4-M8: Debugging: bash -x, set -x and PS4#
When a script misbehaves, make Bash show you what it is doing. Tracing prints every command, after variables have been expanded, just before it runs.
bash -x script.shtraces the whole script without editing it.set -x...set +xtraces just one section.PS4sets the prefix of each trace line. Adding${LINENO}and${FUNCNAME[0]}tells you where each line came from.trap '...' ERRreports the line number of any failing command.bash -n script.shchecks the syntax without running anything.
Key syntax
bash -n script.sh # syntax check only
bash -x script.sh # trace everything
set -x; risky_part; set +x # trace a section
PS4='+ ${BASH_SOURCE}:${LINENO}: ' # richer trace prefix
trap 'echo "error at line $LINENO" >&2' ERR
Example 1 (Simple): Trace a whole script#
bash -x shows each command with its variables already filled in. Trace lines start with +. File: L4-M8-ex1-bash-x.sh
#!/bin/bash
name="web01"
port=8080
url="http://$name:$port/health"
echo "Checking $url"
Run it:
bash -x L4-M8-ex1-bash-x.sh
Output:
+ name=web01
+ port=8080
+ url=http://web01:8080/health
+ echo 'Checking http://web01:8080/health'
Checking http://web01:8080/health
Example 2 (Medium): Trace one section with line numbers#
Only the part between set -x and set +x is traced, and PS4 adds the line number. File: L4-M8-ex2-set-x.sh
#!/bin/bash
# Single quotes on purpose: PS4 is expanded later, for each trace line
# shellcheck disable=SC2016
PS4='+ line ${LINENO}: '
total=0
echo "Adding numbers (traced):"
set -x
for n in 3 4; do
total=$(( total + n ))
done
set +x
echo "Total is $total (tracing is off again)"
Run it:
bash L4-M8-ex2-set-x.sh
Output:
Adding numbers (traced):
+ line 9: for n in 3 4
+ line 10: total=3
+ line 9: for n in 3 4
+ line 10: total=7
+ line 12: set +x
Total is 7 (tracing is off again)
Example 3 (A bit harder): Find where it failed#
An ERR trap names the failing line and command; a richer PS4 shows the function name. File: L4-M8-ex3-err-trap.sh
#!/bin/bash
# shellcheck disable=SC2016
PS4='+ ${FUNCNAME[0]:-main}:${LINENO}: '
trap 'echo "ERROR: line $LINENO: \"$BASH_COMMAND\" exited with $?"' ERR
check_disk() {
local used=$1
(( used < 90 ))
}
echo "Checking disks..."
check_disk 40 && echo "disk A ok"
check_disk 95
echo "Script continued (no set -e). Now tracing check_disk:"
set -x
check_disk 50
set +x
Run it:
bash L4-M8-ex3-err-trap.sh
Output:
Checking disks...
disk A ok
ERROR: line 13: "(( used < 90 ))" exited with 1
Script continued (no set -e). Now tracing check_disk:
+ main:16: check_disk 50
+ check_disk:7: local used=50
+ check_disk:8: (( used < 90 ))
+ main:17: set +x
Note: The ERR trap fires for
check_disk 95because it returned non-zero. It would NOT fire for a failure inside anifcondition or on the left of&&/||, because Bash treats those failures as handled.
Common mistakes#
- Leaving
set -xon in production scripts. Traces can print passwords into logs. - Debugging by guessing. Trace first, then fix.
- Forgetting that trace output goes to stderr. Capture it with
2> trace.log.
L4-M9: ShellCheck: your automatic code reviewer#
ShellCheck is a free tool that reads your script and points out bugs, risky patterns and style problems before they bite: missing quotes, unused variables, wrong test operators and many more.
Install: sudo apt install shellcheck (Debian/Ubuntu), brew install shellcheck (macOS), or paste your script at shellcheck.net. Every script in this tutorial passes ShellCheck.
Each warning has a code (like SC2086) with a wiki page explaining it. If you really mean it, silence a single line with a # shellcheck disable=SC2086 comment just above it, and add a comment explaining why. Add ShellCheck to your CI pipeline so every change is checked automatically.
Key syntax
shellcheck script.sh # check one file
shellcheck *.sh # check many
shellcheck -S warning script.sh # only warnings and errors
# shellcheck disable=SC2086 # silence the next line (explain why!)
Example 1 (Simple): Catch bugs in a broken script#
We write a buggy script into a temp folder and let ShellCheck review it. File: L4-M9-ex1-shellcheck-buggy.sh
#!/bin/bash
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck is not installed. Try: sudo apt install shellcheck (or brew install shellcheck)"
exit 0
fi
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT
cd "$work_dir" || exit 1
cat > buggy.sh <<'EOF'
#!/bin/bash
file=$1
if [ $file == "" ]; then
echo "no file"
fi
for f in $(ls *.log); do
cat $f
done
EOF
shellcheck -f gcc buggy.sh || echo "(shellcheck exit status: $?)"
Run it:
bash L4-M9-ex1-shellcheck-buggy.sh
Sample output (yours will differ: the exact messages depend on your ShellCheck version (0.11.0 here)):
buggy.sh:3:6: note: Double quote to prevent globbing and word splitting. [SC2086]
buggy.sh:6:10: error: Iterating over ls output is fragile. Use globs. [SC2045]
buggy.sh:6:15: note: Use ./*glob* or -- *glob* so names with dashes won't become options. [SC2035]
buggy.sh:7:7: note: Double quote to prevent globbing and word splitting. [SC2086]
(shellcheck exit status: 1)
Example 2 (Medium): Fix it and check again#
The corrected script passes with no output, which is what you want to see. File: L4-M9-ex2-shellcheck-fixed.sh
#!/bin/bash
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck is not installed."
exit 0
fi
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT
cd "$work_dir" || exit 1
cat > fixed.sh <<'EOF'
#!/bin/bash
file=${1:-}
if [[ -z $file ]]; then
echo "no file"
fi
for f in ./*.log; do
[[ -e $f ]] || continue
cat "$f"
done
EOF
if shellcheck fixed.sh; then
echo "fixed.sh: no issues found"
fi
Run it:
bash L4-M9-ex2-shellcheck-fixed.sh
Sample output (yours will differ: needs ShellCheck installed):
fixed.sh: no issues found
Example 3 (A bit harder): Lint a whole folder like CI does#
Check every .sh file, print a summary, and exit non-zero if any file has problems, so a CI pipeline would fail the build. File: L4-M9-ex3-shellcheck-ci.sh
#!/bin/bash
command -v shellcheck >/dev/null 2>&1 || { echo "shellcheck not installed"; exit 0; }
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT
cat > "$work_dir/good.sh" <<'EOF'
#!/bin/bash
echo "ok"
EOF
cat > "$work_dir/needs-quotes.sh" <<'EOF'
#!/bin/bash
name=$1
echo $name
EOF
cat > "$work_dir/loop.sh" <<'EOF'
#!/bin/bash
for i in 1 2; do
echo "$i"
done
EOF
pass=0; fail=0
for f in "$work_dir"/*.sh; do
if shellcheck "$f" >/dev/null 2>&1; then
echo "PASS ${f##*/}"; (( pass += 1 ))
else
echo "FAIL ${f##*/}"; (( fail += 1 ))
fi
done
echo "Summary: $pass passed, $fail failed"
(( fail == 0 ))
Run it:
bash L4-M9-ex3-shellcheck-ci.sh
Sample output (yours will differ: needs ShellCheck installed) (exits with status 1; check with echo $?):
PASS good.sh
PASS loop.sh
FAIL needs-quotes.sh
Summary: 2 passed, 1 failed
Note: Exit status 1 is the point here: one file failed, so a CI job would stop.
Common mistakes#
- Ignoring warnings because "it works on my machine". ShellCheck warnings are usually real bugs waiting for an unusual file name or empty value.
- Disabling a check for the whole file when only one line needs it.
- Running ShellCheck on a script with no shebang. Add
#!/bin/bashso it knows which shell rules to apply.
L4-M10: Scheduling with cron#
cron is the Linux scheduler: it runs commands at fixed times. Each user has a crontab (cron table). Edit yours with crontab -e and list it with crontab -l.
A crontab line has five time fields followed by the command: minute hour day-of-month month day-of-week. * means every value, */15 means every 15th, 1-5 is a range and 8,12,18 is a list.
cron runs your script with almost no environment: a minimal PATH, no aliases, your home folder as the current directory, and no terminal. Use absolute paths, set PATH in the script, redirect output to a log, and use a lock so slow runs never overlap.
On systemd-based Linux, systemd timers are a modern alternative. The ideas are the same.
Key syntax
# m h dom mon dow command
*/5 * * * * /opt/scripts/health.sh >> /var/log/health.log 2>&1
0 2 * * * /opt/scripts/backup.sh
30 9 * * 1-5 /opt/scripts/report.sh
@reboot /opt/scripts/on-boot.sh
Example 1 (Simple): Read cron schedules#
A small table of common schedules and what they mean. File: L4-M10-ex1-cron-read.sh
#!/bin/bash
schedules=(
"*/5 * * * *|every 5 minutes"
"0 2 * * *|every day at 02:00"
"30 9 * * 1-5|09:30, Monday to Friday"
"0 0 1 * *|midnight on the 1st of each month"
"0 8,12,18 * * *|at 08:00, 12:00 and 18:00"
"@reboot|once, when the machine starts"
)
printf "%-18s %s\n" "SCHEDULE" "MEANING"
for s in "${schedules[@]}"; do
printf "%-18s %s\n" "${s%%|*}" "${s#*|}"
done
echo
echo "Field order: minute hour day-of-month month day-of-week"
Run it:
bash L4-M10-ex1-cron-read.sh
Output:
SCHEDULE MEANING
*/5 * * * * every 5 minutes
0 2 * * * every day at 02:00
30 9 * * 1-5 09:30, Monday to Friday
0 0 1 * * midnight on the 1st of each month
0 8,12,18 * * * at 08:00, 12:00 and 18:00
@reboot once, when the machine starts
Field order: minute hour day-of-month month day-of-week
Example 2 (Medium): Validate a schedule before installing it#
Check that there are 5 fields and that each number is in range. That catches typos before cron silently rejects the line. File: L4-M10-ex2-cron-validate.sh
#!/bin/bash
names=(minute hour day month weekday)
mins=(0 0 1 1 0)
maxs=(59 23 31 12 7)
check_value() { # check_value NUMBER INDEX
(( 10#$1 >= mins[$2] && 10#$1 <= maxs[$2] ))
}
validate() {
local -a f
read -r -a f <<< "$1"
(( ${#f[@]} == 5 )) || { echo "INVALID: ${#f[@]} fields, need 5"; return 1; }
local i item a b
for i in 0 1 2 3 4; do
IFS=, read -r -a items <<< "${f[i]}"
for item in "${items[@]}"; do
if [[ $item == "*" || $item =~ ^\*/[1-9][0-9]*$ ]]; then
continue
elif [[ $item =~ ^([0-9]+)-([0-9]+)$ ]]; then
a=${BASH_REMATCH[1]}; b=${BASH_REMATCH[2]}
if ! { check_value "$a" "$i" && check_value "$b" "$i" && (( a <= b )); }; then
echo "INVALID: ${names[i]} range '$item'"; return 1
fi
elif [[ $item =~ ^[0-9]+$ ]]; then
if ! check_value "$item" "$i"; then
echo "INVALID: ${names[i]} '$item' (allowed ${mins[i]}-${maxs[i]})"; return 1
fi
else
echo "INVALID: ${names[i]} '$item' is not understood"; return 1
fi
done
done
echo "valid"
}
for expr in "*/15 * * * *" "30 9 * * 1-5" "0 8,12,18 * * *" "0 25 * * *" "0 0 1 *" "61 * * * *" "0 9 * * 5-1"; do
printf "%-18s -> %s\n" "$expr" "$(validate "$expr")"
done
Run it:
bash L4-M10-ex2-cron-validate.sh
Output:
*/15 * * * * -> valid
30 9 * * 1-5 -> valid
0 8,12,18 * * * -> valid
0 25 * * * -> INVALID: hour '25' (allowed 0-23)
0 0 1 * -> INVALID: 4 fields, need 5
61 * * * * -> INVALID: minute '61' (allowed 0-59)
0 9 * * 5-1 -> INVALID: weekday range '5-1'
Example 3 (A bit harder): A cron-safe job wrapper#
Everything a cron job needs: its own PATH, a lock against overlapping runs, a timestamped log, and all output captured. We simulate three cron runs, where the second starts while the first is still busy. File: L4-M10-ex3-cron-wrapper.sh
#!/bin/bash
set -euo pipefail
export PATH=/usr/local/bin:/usr/bin:/bin
base=$(mktemp -d) # stands in for /var/lib/myjob
trap 'rm -rf "$base"' EXIT
log_file="$base/job.log"
lock_dir="$base/job.lock"
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$log_file"; }
job() {
log "job: cleaning old reports"
log "job: done"
}
cron_run() {
if ! mkdir "$lock_dir" 2>/dev/null; then
log "previous run still active, skipping"
return 0
fi
log "start (pid $$)"
job >> "$log_file" 2>&1
log "end"
rmdir "$lock_dir"
}
cron_run # 1st run
mkdir "$lock_dir"; cron_run; rmdir "$lock_dir" # 2nd run overlaps a busy run
cron_run # 3rd run
echo "Log written by the simulated cron runs:"
cat "$log_file"
echo
echo "Crontab line to run this every 10 minutes:"
echo "*/10 * * * * /opt/scripts/cleanup.sh >> /var/log/cleanup-cron.log 2>&1"
Run it:
bash L4-M10-ex3-cron-wrapper.sh
Sample output (yours will differ: timestamps and the PID change every run):
Log written by the simulated cron runs:
2026-10-08 18:55:33 start (pid 2327560)
2026-10-08 18:55:33 job: cleaning old reports
2026-10-08 18:55:33 job: done
2026-10-08 18:55:33 end
2026-10-08 18:55:33 previous run still active, skipping
2026-10-08 18:55:33 start (pid 2327560)
2026-10-08 18:55:33 job: cleaning old reports
2026-10-08 18:55:33 job: done
2026-10-08 18:55:33 end
Crontab line to run this every 10 minutes:
*/10 * * * * /opt/scripts/cleanup.sh >> /var/log/cleanup-cron.log 2>&1
Common mistakes#
- Using relative paths or commands that are not in cron's minimal PATH, so the job works in your terminal but fails in cron.
- Not redirecting output, so errors vanish (or get emailed to a mailbox nobody reads).
- Forgetting the
%rule: in a crontab line%means a new line. Escape it as\%(for example indate +\%F). - Overlapping runs of a slow job. Use a lock (
flock -nor a lock folder).
L4-M11: Real DevOps scripts: backup, log rotation, health checks#
Time to combine everything. These three scripts mirror jobs you will find on real servers:
- a dated backup with
tar, - log rotation with retention: keep the newest N compressed logs and delete older ones,
- a health check with retries and a meaningful exit code.
To keep them runnable anywhere, offline and safely, they work inside a temp folder, and the health check uses a local stand-in instead of the network. The comments show the one line you would change for production (for example, calling curl for real).
Key syntax
tar -czf "backup-$(date +%F).tar.gz" -C /src . # dated archive
ls -1 backup-*.tar.gz | sort | head -n -3 # all but newest 3 (GNU)
gzip app.log.1 # compress a rotated log
curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$url"
Example 1 (Simple): Dated backup with tar#
Archive a folder into a file named with today's date, then list what is inside. File: L4-M11-ex1-backup-tar.sh
#!/bin/bash
set -euo pipefail
base=$(mktemp -d)
trap 'rm -rf "$base"' EXIT
mkdir -p "$base/site/css" "$base/backups"
echo "<h1>Home</h1>" > "$base/site/index.html"
echo "body{}" > "$base/site/css/main.css"
stamp=$(date +%Y-%m-%d_%H%M)
archive="$base/backups/site-$stamp.tar.gz"
tar -czf "$archive" -C "$base" site
echo "Created: ${archive##*/}"
echo "Contents:"
tar -tzf "$archive" | sort | sed 's/^/ /'
Run it:
bash L4-M11-ex1-backup-tar.sh
Sample output (yours will differ: the archive name contains the current date and time):
Created: site-2026-10-08_1855.tar.gz
Contents:
site/
site/css/
site/css/main.css
site/index.html
Example 2 (Medium): Log rotation with retention#
Each "day" the app writes a log; we rotate it to .1.gz, shift older archives up, and keep only 3. File: L4-M11-ex2-log-rotate.sh
#!/bin/bash
set -euo pipefail
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
log="$dir/app.log"
keep=3
rotate() {
[[ -s $log ]] || { echo " nothing to rotate"; return 0; }
rm -f "$log.$keep.gz"
local i
for (( i = keep - 1; i >= 1; i-- )); do
if [[ -f $log.$i.gz ]]; then mv "$log.$i.gz" "$log.$(( i + 1 )).gz"; fi
done
mv "$log" "$log.1"
gzip "$log.1"
: > "$log"
}
for day in 1 2 3 4 5; do
printf 'day %d: request served\n' "$day" >> "$log"
rotate
files=("$dir"/app.log*)
echo "after day $day: ${files[*]##*/}"
done
echo "Oldest kept archive (app.log.3.gz) contains: $(gzip -dc "$log.3.gz")"
Run it:
bash L4-M11-ex2-log-rotate.sh
Output:
after day 1: app.log app.log.1.gz
after day 2: app.log app.log.1.gz app.log.2.gz
after day 3: app.log app.log.1.gz app.log.2.gz app.log.3.gz
after day 4: app.log app.log.1.gz app.log.2.gz app.log.3.gz
after day 5: app.log app.log.1.gz app.log.2.gz app.log.3.gz
Oldest kept archive (app.log.3.gz) contains: day 3: request served
Example 3 (A bit harder): Health check with retries#
Check several endpoints, retry failures, print a summary and exit 1 if anything is down. The probe function is a stand-in for curl. File: L4-M11-ex3-health-check.sh
#!/bin/bash
# Stand-in "network": the responses each URL will give, in order.
declare -A responses=(
[https://shop.example.com/health]="200"
[https://api.example.com/health]="503 503 200"
[https://old.example.com/health]="000 000 000"
)
declare -A calls=()
max_tries=3
# In production replace the body with:
# code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$1")
probe() {
local url=$1 n list
n=${calls[$url]:-0}
calls[$url]=$(( n + 1 ))
read -r -a list <<< "${responses[$url]}"
code=${list[n]:-${list[-1]}}
}
check() {
local url=$1 try
for (( try = 1; try <= max_tries; try++ )); do
probe "$url"
if (( code >= 200 && code < 400 )); then
printf "UP %-34s code=%s tries=%d\n" "$url" "$code" "$try"
return 0
fi
# sleep 2 # real scripts wait between tries
done
printf "DOWN %-34s code=%s tries=%d\n" "$url" "$code" "$max_tries"
return 1
}
down=0
for url in https://shop.example.com/health https://api.example.com/health https://old.example.com/health; do
check "$url" || (( down += 1 ))
done
echo "Summary: $(( ${#responses[@]} - down )) up, $down down"
(( down == 0 ))
Run it:
bash L4-M11-ex3-health-check.sh
Output (exits with status 1; check with echo $?):
UP https://shop.example.com/health code=200 tries=1
UP https://api.example.com/health code=200 tries=3
DOWN https://old.example.com/health code=000 tries=3
Summary: 2 up, 1 down
Note:
probesets a global variablecodeinstead of usingecho, because$(probe ...)would run in a subshell and the retry counter incallswould be lost (the Level 2 subshell lesson again). Exit status 1 = at least one endpoint is down, which is exactly what a monitoring system or cron wrapper needs.
Common mistakes#
- Backups you have never restored. Test the restore (
tar -tzf, then a real extraction) regularly. - Deleting "old" files with an unquoted glob or an empty variable (
rm -rf "$dir"/*withdirempty means/*!). Useset -uand check that variables are not empty. - Health checks without timeouts (
curl --max-time), which hang forever. - Exit code 0 even when checks fail, so monitoring never alerts.
Level 4 practice exercises#
Try each one yourself before opening the solution.
Exercise 1#
Write a script with set -euo pipefail and an EXIT trap that removes a temp folder and prints cleaned up. Make it fail on purpose by running false halfway through, and confirm the cleanup still runs.
Hint: trap '...' EXIT runs even when set -e stops the script.
Show solution
File: L4-P1-solution.sh
#!/bin/bash
set -euo pipefail
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"; echo "cleaned up"' EXIT
echo "step 1 ok"
false
echo "step 2 (never runs)"
Run it:
bash L4-P1-solution.sh
Output (exits with status 1; check with echo $?):
step 1 ok
cleaned up
Exercise 2#
Use getopts to accept -n NAME (required) and -g GREETING (optional, default Hello). Print GREETING, NAME!. If -n is missing, print a usage line to stderr and exit 2. Run with -g Namaste -n Pushpjeet.
Hint: Check the required value after the loop.
Show solution
File: L4-P2-solution.sh
#!/bin/bash
greeting="Hello"
name=""
while getopts ":n:g:" opt; do
case $opt in
n) name=$OPTARG ;;
g) greeting=$OPTARG ;;
*) echo "usage: $0 -n NAME [-g GREETING]" >&2; exit 2 ;;
esac
done
[[ -n $name ]] || { echo "usage: $0 -n NAME [-g GREETING]" >&2; exit 2; }
echo "$greeting, $name!"
Run it:
bash L4-P2-solution.sh -g Namaste -n Pushpjeet
Output:
Namaste, Pushpjeet!
Exercise 3#
Backup retention: in a temp folder create backup-2026-10-01.tar.gz to backup-2026-10-06.tar.gz. Keep only the newest 3 (by the date in the name) and delete the rest, printing what was deleted and kept.
Hint: ISO dates sort correctly as text. Sort the names, then delete all but the last N.
Show solution
File: L4-P3-solution.sh
#!/bin/bash
set -euo pipefail
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
for d in 01 02 03 04 05 06; do touch "$dir/backup-2026-10-$d.tar.gz"; done
keep=3
mapfile -t all < <(printf '%s\n' "$dir"/backup-*.tar.gz | sort)
remove=$(( ${#all[@]} - keep ))
for (( i = 0; i < remove; i++ )); do
rm -- "${all[i]}"
echo "deleted ${all[i]##*/}"
done
for f in "$dir"/backup-*.tar.gz; do echo "kept ${f##*/}"; done
Run it:
bash L4-P3-solution.sh
Output:
deleted backup-2026-10-01.tar.gz
deleted backup-2026-10-02.tar.gz
deleted backup-2026-10-03.tar.gz
kept backup-2026-10-04.tar.gz
kept backup-2026-10-05.tar.gz
kept backup-2026-10-06.tar.gz
Exercise 4#
Write valid_ipv4 and test it on 192.168.1.10, 256.1.1.1, 10.0.0, 172.16.0.255, printing valid/invalid for each.
Hint: Regex for the shape, then check each number is 255 or less.
Show solution
File: L4-P4-solution.sh
#!/bin/bash
valid_ipv4() {
local IFS=. part
local -a p
[[ $1 =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || return 1
read -r -a p <<< "$1"
for part in "${p[@]}"; do (( 10#$part <= 255 )) || return 1; done
}
for ip in 192.168.1.10 256.1.1.1 10.0.0 172.16.0.255; do
if valid_ipv4 "$ip"; then echo "$ip valid"; else echo "$ip invalid"; fi
done
Run it:
bash L4-P4-solution.sh
Output:
192.168.1.10 valid
256.1.1.1 invalid
10.0.0 invalid
172.16.0.255 valid
Exercise 5#
Write an idempotent ensure_line LINE FILE function and call it three times with the same line. The file must end up containing the line exactly once.
Hint: grep -qxF matches the whole line literally.
Show solution
File: L4-P5-solution.sh
#!/bin/bash
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT
f="$work_dir/profile"
ensure_line() {
grep -qxF -- "$1" "$2" 2>/dev/null || echo "$1" >> "$2"
}
for _ in 1 2 3; do ensure_line 'export EDITOR=vim' "$f"; done
echo "Lines in file: $(wc -l < "$f")"
cat "$f"
Run it:
bash L4-P5-solution.sh
Output:
Lines in file: 1
export EDITOR=vim
Level 4: check yourself#
Five multiple-choice questions cover Level 4. Each answer comes with an explanation of why every option is right or wrong, and you earn XP as you go.
Capstone project: server-health report tool#
Congratulations on reaching the capstone! You will now read (and then build yourself) a complete, production-style tool that uses almost every idea from this tutorial.
Read the spec first, try to sketch the functions yourself, then study the full code and run it.
Specification#
- Goal: a single command,
capstone-server-health.sh, that prints a one-page health report for a Linux server and returns an exit code that monitoring tools and cron wrappers understand. - Checks: root disk usage %, memory usage %, load average as a % of CPU cores, uptime, and the state of named services (for example nginx, sshd).
- Flags (getopts):
-wwarning %,-ccritical %,-sservice list,-fmetrics file (demo/test mode),-oreport file,-llog file,-qquiet,-vverbose,-Vversion,-hhelp. - Exit codes: 0 OK, 1 WARNING, 2 CRITICAL, 3 usage or internal error (the same convention Nagios-style monitoring uses).
- Quality bar: strict mode, input validation with clear errors, timestamped logging to stderr (and optionally a file), a trap for cleanup and Ctrl+C, atomic report writing with mktemp + mv, small single-purpose functions, a
mainfunction, and ShellCheck-clean code. - Testability:
-f FILEreads metrics from a key=value file instead of the live system, so you can reproduce any scenario (full disk, stopped service) on any machine, offline.
Design: one function per job#
parse_args: getopts loop, then validation (is_percent, warn < crit, readable files).load_fixture/collect_live: fill the associative arrayMwith metrics.evaluate: grade each metric against the thresholds, count warnings and criticals, and build report rows.render/write_report: format the table and print it or write it atomically.log,die,cleanup: cross-cutting helpers;trap cleanup EXITand an INT/TERM trap.main: wires everything together and maps the overall result to the exit code.
Full code#
File: capstone-server-health.sh
#!/bin/bash
# capstone-server-health.sh - one-page health report for a Linux server.
#
# Checks disk, memory, load, uptime and services, prints a table and exits
# with a monitoring-friendly code:
# 0 = OK, 1 = WARNING, 2 = CRITICAL, 3 = usage or internal error
#
# Live mode reads Linux /proc and df. Demo/test mode (-f FILE) reads metrics
# from a key=value file, so the report can be reproduced on any machine.
set -euo pipefail
readonly VERSION="1.0.0"
readonly SCRIPT=${0##*/}
# ---------- defaults ----------
warn=80
crit=90
output=""
fixture=""
log_file=""
quiet=0
verbose=0
services=()
declare -A M=() # collected metrics
rows=() # report rows: "check|value|status|note"
n_warn=0
n_crit=0
status=""
work_dir=""
# ---------- helpers ----------
usage() {
cat <<EOF
Usage: $SCRIPT [options]
Options:
-w PCT warning threshold in percent (default $warn)
-c PCT critical threshold in percent (default $crit)
-s LIST comma-separated services to check, e.g. nginx,sshd
-f FILE read metrics from FILE instead of this machine (demo/testing)
-o FILE write the report to FILE instead of the screen
-l FILE also append log lines to FILE
-q quiet: only log errors
-v verbose: include debug log lines
-V print version and exit
-h show this help and exit
Exit codes: 0 OK, 1 WARNING, 2 CRITICAL, 3 error
EOF
}
log() {
local level=$1; shift
if (( quiet )) && [[ $level != ERROR ]]; then return 0; fi
if [[ $level == DEBUG ]] && (( ! verbose )); then return 0; fi
local line
line="$(date '+%Y-%m-%d %H:%M:%S') [$level] $*"
echo "$line" >&2
if [[ -n $log_file ]]; then echo "$line" >> "$log_file"; fi
}
die() {
log ERROR "$*"
exit 3
}
# cleanup is called by the EXIT trap below
# shellcheck disable=SC2329
cleanup() {
if [[ -n $work_dir && -d $work_dir ]]; then rm -rf "$work_dir"; fi
}
trap cleanup EXIT
trap 'log ERROR "interrupted"; exit 130' INT TERM
is_percent() { [[ $1 =~ ^[0-9]+$ ]] && (( 10#$1 >= 1 && 10#$1 <= 100 )); }
# ---------- argument parsing and validation ----------
parse_args() {
local opt
while getopts ":w:c:s:f:o:l:qvVh" opt; do
case $opt in
w) warn=$OPTARG ;;
c) crit=$OPTARG ;;
s) IFS=, read -r -a services <<< "$OPTARG" ;;
f) fixture=$OPTARG ;;
o) output=$OPTARG ;;
l) log_file=$OPTARG ;;
q) quiet=1 ;;
v) verbose=1 ;;
V) echo "$SCRIPT $VERSION"; exit 0 ;;
h) usage; exit 0 ;;
:) usage >&2; die "option -$OPTARG needs a value" ;;
\?) usage >&2; die "unknown option -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
(( $# == 0 )) || die "unexpected argument(s): $*"
is_percent "$warn" || die "-w must be a whole number 1-100 (got '$warn')"
is_percent "$crit" || die "-c must be a whole number 1-100 (got '$crit')"
(( warn < crit )) || die "-w ($warn) must be lower than -c ($crit)"
if [[ -n $fixture ]]; then
[[ -f $fixture && -r $fixture ]] || die "cannot read metrics file '$fixture'"
fi
if [[ -n $output ]]; then
local out_dir
out_dir=$(dirname -- "$output")
[[ -d $out_dir && -w $out_dir ]] || die "cannot write to folder '$out_dir'"
fi
}
# ---------- collecting metrics ----------
load_fixture() {
local key value line_no=0
while IFS='=' read -r key value || [[ -n $key ]]; do
(( line_no += 1 ))
[[ -z $key || $key == \#* ]] && continue
[[ $key =~ ^[a-z0-9_]+$ && -n $value ]] || die "$fixture line $line_no: expected key=value"
M[$key]=$value
done < "$fixture"
log DEBUG "loaded ${#M[@]} metrics from $fixture"
}
collect_live() {
[[ -r /proc/meminfo && -r /proc/loadavg ]] || die "live mode needs Linux /proc; use -f FILE for a demo"
local total avail svc
M[host]=$(hostname)
M[disk_root]=$(df -P / | awk 'NR == 2 { gsub("%", "", $5); print $5 }')
total=$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo)
avail=$(awk '/^MemAvailable:/ { print $2 }' /proc/meminfo)
M[mem_used]=$(( (total - avail) * 100 / total ))
M[load1]=$(cut -d' ' -f1 /proc/loadavg)
M[cpus]=$(getconf _NPROCESSORS_ONLN)
M[uptime_s]=$(cut -d. -f1 /proc/uptime)
for svc in "${services[@]}"; do
if pgrep -x "$svc" > /dev/null; then M[service_$svc]=running; else M[service_$svc]=stopped; fi
done
log DEBUG "collected live metrics from ${M[host]}"
}
# ---------- evaluating ----------
# grade sets the global "status" instead of echoing it: calling it inside
# $( ) would run it in a subshell and the counters would be lost.
grade() { # grade VALUE
if (( $1 >= crit )); then status="CRITICAL"; (( n_crit += 1 ))
elif (( $1 >= warn )); then status="WARNING"; (( n_warn += 1 ))
else status="OK"; fi
}
add_row() { rows+=("$1|$2|$3|${4:-}"); }
evaluate() {
local key load_pct days hours svc
for key in host disk_root mem_used load1 cpus uptime_s; do
[[ -n ${M[$key]:-} ]] || die "metric '$key' is missing"
done
grade "${M[disk_root]}"
add_row "disk /" "${M[disk_root]}%" "$status"
grade "${M[mem_used]}"
add_row "memory" "${M[mem_used]}%" "$status"
load_pct=$(awk -v l="${M[load1]}" -v c="${M[cpus]}" 'BEGIN { printf "%d", l * 100 / c }')
grade "$load_pct"
add_row "load (${M[cpus]} cpus)" "${load_pct}%" "$status" "load1 ${M[load1]}"
days=$(( M[uptime_s] / 86400 ))
hours=$(( M[uptime_s] % 86400 / 3600 ))
add_row "uptime" "${days}d ${hours}h" "INFO"
if (( ${#services[@]} == 0 )); then
for key in $(printf '%s\n' "${!M[@]}" | sort); do
if [[ $key == service_* ]]; then services+=("${key#service_}"); fi
done
fi
for svc in "${services[@]}"; do
case ${M[service_$svc]:-unknown} in
running) add_row "service $svc" "running" "OK" ;;
*) (( n_crit += 1 )); add_row "service $svc" "${M[service_$svc]:-unknown}" "CRITICAL" ;;
esac
done
log DEBUG "evaluation done: $n_crit critical, $n_warn warning"
}
overall() {
if (( n_crit > 0 )); then echo "CRITICAL"
elif (( n_warn > 0 )); then echo "WARNING"
else echo "OK"; fi
}
# ---------- reporting ----------
render() {
local rule row check value status note
rule=$(printf '%*s' 56 '' | tr ' ' '=')
echo "$rule"
echo " Server health report: ${M[host]}"
echo " Generated: $(date '+%Y-%m-%d %H:%M:%S') Thresholds: warn ${warn}% / crit ${crit}%"
echo "$rule"
printf "%-18s %-10s %-9s %s\n" "CHECK" "VALUE" "STATUS" "NOTE"
for row in "${rows[@]}"; do
IFS='|' read -r check value status note <<< "$row"
printf "%-18s %-10s %-9s %s\n" "$check" "$value" "$status" "$note"
done
echo "${rule//=/-}"
echo "OVERALL: $(overall) ($n_crit critical, $n_warn warning)"
}
write_report() {
if [[ -z $output ]]; then
render
return
fi
work_dir=$(mktemp -d)
render > "$work_dir/report.txt"
mv "$work_dir/report.txt" "$output" # atomic replace
log INFO "report written to $output"
}
main() {
parse_args "$@"
log INFO "starting $SCRIPT $VERSION (warn=$warn crit=$crit)"
if [[ -n $fixture ]]; then load_fixture; else collect_live; fi
evaluate
write_report
local result
result=$(overall)
log INFO "finished: $result"
case $result in
OK) exit 0 ;;
WARNING) exit 1 ;;
*) exit 2 ;;
esac
}
main "$@"
Sample metrics file used by -f (file: capstone-sample-metrics.txt):
# Sample metrics for demo and test runs (format: key=value)
host=web01.example.com
disk_root=91
mem_used=72
load1=3.40
cpus=4
uptime_s=1040400
service_nginx=running
service_sshd=running
service_cron=stopped
Sample runs#
Help#
Run it:
bash capstone-server-health.sh -h
Output:
Usage: capstone-server-health.sh [options]
Options:
-w PCT warning threshold in percent (default 80)
-c PCT critical threshold in percent (default 90)
-s LIST comma-separated services to check, e.g. nginx,sshd
-f FILE read metrics from FILE instead of this machine (demo/testing)
-o FILE write the report to FILE instead of the screen
-l FILE also append log lines to FILE
-q quiet: only log errors
-v verbose: include debug log lines
-V print version and exit
-h show this help and exit
Exit codes: 0 OK, 1 WARNING, 2 CRITICAL, 3 error
Demo run with the sample metrics (logs go to stderr, shown inline)#
Run it:
bash capstone-server-health.sh -f capstone-sample-metrics.txt
Sample output (yours will differ: the timestamps change every run) (exits with status 2; check with echo $?):
2026-10-08 18:55:33 [INFO] starting capstone-server-health.sh 1.0.0 (warn=80 crit=90)
========================================================
Server health report: web01.example.com
Generated: 2026-10-08 18:55:33 Thresholds: warn 80% / crit 90%
========================================================
CHECK VALUE STATUS NOTE
disk / 91% CRITICAL
memory 72% OK
load (4 cpus) 85% WARNING load1 3.40
uptime 12d 1h INFO
service cron stopped CRITICAL
service nginx running OK
service sshd running OK
--------------------------------------------------------
OVERALL: CRITICAL (2 critical, 1 warning)
2026-10-08 18:55:33 [INFO] finished: CRITICAL
Custom thresholds, chosen services, report to a file, verbose logs#
Run it:
bash capstone-server-health.sh -f capstone-sample-metrics.txt -w 85 -c 95 -s nginx,sshd -o report.txt -v; echo "exit status: $?"; cat report.txt
Sample output (yours will differ: the timestamps change every run):
2026-10-08 18:55:34 [INFO] starting capstone-server-health.sh 1.0.0 (warn=85 crit=95)
2026-10-08 18:55:34 [DEBUG] loaded 9 metrics from capstone-sample-metrics.txt
2026-10-08 18:55:34 [DEBUG] evaluation done: 0 critical, 2 warning
2026-10-08 18:55:34 [INFO] report written to report.txt
2026-10-08 18:55:34 [INFO] finished: WARNING
exit status: 1
========================================================
Server health report: web01.example.com
Generated: 2026-10-08 18:55:34 Thresholds: warn 85% / crit 95%
========================================================
CHECK VALUE STATUS NOTE
disk / 91% WARNING
memory 72% OK
load (4 cpus) 85% WARNING load1 3.40
uptime 12d 1h INFO
service nginx running OK
service sshd running OK
--------------------------------------------------------
OVERALL: WARNING (0 critical, 2 warning)
Bad input is rejected with exit code 3#
Run it:
bash capstone-server-health.sh -w 95 -c 90
Sample output (yours will differ: the timestamp changes every run) (exits with status 3; check with echo $?):
2026-10-08 18:55:34 [ERROR] -w (95) must be lower than -c (90)
Live run on a real machine (quiet mode)#
Run it:
bash capstone-server-health.sh -q -s bash; echo "exit status: $?"
Sample output (yours will differ: live values from the machine that ran it; the hostname is shown as web-01):
========================================================
Server health report: web-01
Generated: 2026-10-08 18:55:34 Thresholds: warn 80% / crit 90%
========================================================
CHECK VALUE STATUS NOTE
disk / 13% OK
memory 62% OK
load (8 cpus) 12% OK load1 1.02
uptime 4d 23h INFO
service bash running OK
--------------------------------------------------------
OVERALL: OK (0 critical, 0 warning)
exit status: 0
Make it your own#
- Add
-jto print JSON instead of a table (hint: build it with printf, keeping the same rows). - Add an inode check (
df -Pi /). - Add a
-moption that checks several mount points. - Schedule it from cron every 10 minutes with a lock, and rotate its log file with the Level 4 rotation script.
- Write a tiny test script that runs the tool against several fixture files and asserts the exit codes.
Common mistakes#
Every module above ends with its own list of mistakes. These are the ones that come up again and again across levels, in one place for revision.
| Mistake | What goes wrong | Fix | Module |
|---|---|---|---|
Spaces around = (count = 5) |
Bash runs a command called count |
count=5 |
L0-M4 |
Unquoted variables (rm $file) |
Names with spaces split into several words; empty values vanish | Quote every expansion: "$file", "$@", "${arr[@]}" |
L0-M5, L3-M3, L3-M4 |
> or < inside [ ] |
Treated as a redirection; [ 10 > 9 ] creates a file called 9 |
-gt/-lt in [ ], or (( a > b )) |
L1-M3, L1-M7 |
[ false ] expected to be false |
It tests a non-empty string, which is true | if false; then (no brackets) |
L1-M2 |
Checking $? too late |
It reports the last command, often an echo |
Save it straight away: status=$? |
L1-M1 |
cmd && ok plus an OR fallback, used as if-else |
If ok fails, the fallback runs as well |
Use a real if/else |
L1-M1 |
for line in $(cat file) |
Loops over words, not lines, and expands * |
while IFS= read -r line; do ...; done < file |
L2-M7 |
Piping into a while loop |
Variables set inside the loop are lost | Redirect the file into the loop: done < file |
L2-M7 |
{1..$n} |
Ranges do not expand variables | for (( i = 1; i <= n; i++ )) |
L2-M2 |
Leading zeros in arithmetic (08) |
Read as an invalid octal number | 10#$n |
L2-M9, L4-M4 |
return "text" or return 300 |
Return values are statuses from 0 to 255 | echo the data and capture it with $( ) |
L3-M2 |
local x=$(cmd) then $? |
$? reports local, not cmd |
local x; x=$(cmd) |
L3-M2, L4-M1 |
cmd 2>&1 > file |
Errors still go to the screen | cmd > file 2>&1 (order matters) |
L3-M8 |
Trusting set -e to catch everything |
It is ignored in if conditions and on the left of && and OR lists; ((count++)) at 0 kills the script |
Check critical commands yourself; use (( count += 1 )) |
L4-M1 |
trap "rm $file" EXIT |
$file is expanded when the trap is set, not when it runs |
Single quotes or a cleanup function | L4-M2 |
Fixed temp names like /tmp/out.txt |
Clashes between runs and users; junk left after failures | mktemp or mktemp -d plus trap cleanup EXIT |
L4-M6 |
| A script that only works on the first run | mkdir fails with "File exists"; config lines get appended twice |
mkdir -p, check before you change, run it twice in testing |
L4-M7 |
Leaving set -x on in production |
Traces can print secrets into logs | Trace only the section you are debugging | L4-M8 |
| Relative paths in cron jobs | Works in the terminal, fails at 2 a.m. | Absolute paths, PATH set in the script, output redirected to a log |
L4-M10 |
rm -rf "$dir"/* with an empty $dir |
Expands to /* |
set -u and an explicit non-empty check before deleting |
L4-M11 |
| Health checks that always exit 0 | Monitoring never alerts | Exit non-zero when a check fails | L4-M11 |
Interview questions#
Ten questions that come up in DevOps, cloud and Linux admin interviews, with model answers.
1. What is the difference between "$@" and "$*"?
Both expand to the script's (or function's) arguments. "$@" expands to each argument as a separate word, keeping arguments with spaces intact, so it is what you use to loop over or forward arguments. "$*" joins all arguments into one single string separated by the first character of IFS (a space by default). Unquoted, both are re-split and globbed, which is almost always a bug.
2. What does set -euo pipefail do, and what are its limits?
-e exits on the first failing command, -u makes unset variables an error, and pipefail makes a pipeline fail if any stage fails. Limits: -e is ignored in if/while conditions and on the left of &&/||. ((x++)) with x=0 returns 1 and kills the script. local v=$(cmd) masks cmd's status. grep with no match returns 1. So I use it as a safety net, but still check critical commands explicitly and add || true where failure is acceptable.
3. How do you guarantee temporary files are cleaned up?
Create them with mktemp or mktemp -d (unique, private names; never fixed /tmp/foo names), and register trap cleanup EXIT right after creating them. The EXIT trap runs on normal exit, on exit N, on set -e failures, and after an INT/TERM handler calls exit, so cleanup always happens. Exceptions are kill -9 and power loss.
4. Explain [ ], [[ ]] and (( )).
[ is the POSIX test command, so you must quote variables, and > would be a redirection. [[ ]] is Bash syntax: no word splitting, &&/|| inside, glob patterns with ==, and regex with =~. (( )) evaluates integer arithmetic with C-style operators and returns 0 when the result is non-zero. In Bash I use [[ ]] for strings and files, (( )) for numbers, and [ ] only for portable sh scripts.
5. How do you read a file line by line correctly, and why not for line in $(cat file)?
while IFS= read -r line || [[ -n $line ]]; do ...; done < file. IFS= keeps leading and trailing spaces, -r keeps backslashes, the || part handles a last line without a newline, and redirecting with < keeps the loop in the current shell, so variables survive. for line in $(cat file) splits on all whitespace (words, not lines) and expands globs like *.
6. What does cmd > out.log 2>&1 do, and why does the order matter?
It sends stdout to out.log, then makes stderr (fd 2) a copy of wherever stdout (fd 1) points now, which is the file, so both streams end up in out.log. Redirections are processed left to right. cmd 2>&1 > out.log copies stderr to the terminal first and only then moves stdout, so errors still appear on screen. &> out.log is the Bash shortcut for both.
7. How do you debug a failing Bash script?
Start with bash -n for syntax and ShellCheck for common bugs. Then trace with bash -x script.sh, or wrap the suspicious section in set -x ... set +x, with a richer PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: '. An ERR trap that prints $LINENO and $BASH_COMMAND finds the failing line. I also check exit codes ($?, PIPESTATUS) and reproduce the problem with the same environment, for example cron's minimal PATH.
8. How do you handle command-line options in a script?
For short options I use the getopts builtin with an option string like ":e:c:vh" (a colon after a letter means it takes a value in $OPTARG; the leading colon enables custom error handling), handle : and \? cases, then shift $((OPTIND - 1)) to leave positional arguments. I validate values after parsing, print usage to stderr with exit code 2 on bad input, and use local OPTIND if parsing inside a function. For long options, a while (( $# )); case $1 ... shift loop.
9. What makes a script idempotent, and why does it matter?
Running it once or many times yields the same end state. It matters because scripts get re-run after partial failures, from cron, or by configuration management. Techniques: mkdir -p, ln -sfn, check-before-change (grep -qxF line file || echo line >> file), marker files for completed steps, atomic writes (temp file + mv), and a lock (flock -n or mkdir lockdir) so two copies don't run at once.
10. A script works when you run it by hand but fails from cron. How do you troubleshoot it?
cron uses a minimal environment: a short PATH, no profile or aliases, a different working directory, and no TTY. I capture output (>> /var/log/job.log 2>&1), set PATH explicitly or use absolute paths, cd to a known folder, avoid interactive commands, escape % in the crontab line, and confirm the schedule and the cron daemon's logs (grep CRON /var/log/syslog or journalctl -u cron). I also add a lock in case runs overlap, and test by running with env -i to simulate the bare environment.
Where to go next#
- Rebuild the capstone from a blank file without looking at the solution, then add one item from its "Make it your own" list.
- Automate one task you repeat every week. Start from the production template in the cheat sheet, run the script twice to prove it is idempotent, and make it pass ShellCheck.
- Take the full quiz and review every question you miss: Take the Bash quiz (25 questions)
- Go back over the Linux basics your scripts depend on. Permissions, processes, archives and remote access are covered module by module in Linux from Scratch.
- Use Bash on cloud servers. Bootstrap scripts, deployment steps and scheduled jobs on cloud instances are often written in Bash. If AWS is your next step, see AWS training.
- Heading towards AI/ML? Stage 0 of the AI/ML roadmap asks for enough Bash to run a script on a remote machine and read its logs. The first levels of this tutorial cover that.
Further learning#
Official references only, so you can check any detail at the source.
GNU Bash Reference Manual (gnu.org)
- Bash Reference Manual: all formats · single-page HTML
- Quoting · Special Parameters · Shell Parameter Expansion
- Conditional Constructs · Bash Conditional Expressions · Looping Constructs · Shell Arithmetic
- Shell Functions · Arrays · Redirections
- The Set Builtin (
set -e,-u,-x,-o pipefail) · Bourne Shell Builtins (trap,getopts,return,shift) · Bash Builtins (declare,local,mapfile,read,printf)
ShellCheck
- shellcheck.net: paste a script and get the same warnings as the command-line tool.
- ShellCheck wiki: one page per warning code, for example SC2086 (double quote to prevent globbing and word splitting).
- ShellCheck on GitHub: installation for every platform and editor integrations. How to ignore a warning explains the
# shellcheck disable=comments used in a few scripts here.
FAQ#
Do I need Linux to follow this tutorial?
You need Bash 4 or newer. Any Linux distribution works, and so does WSL on Windows. On macOS, the default Bash is 3.2, so install a newer Bash (for example with Homebrew) and run the scripts with it; otherwise associative arrays, ${v^^} and mapfile will fail. No internet access, cloud account or admin rights are needed.
How is this different from M14 in Linux from Scratch?
M14 is a short introduction inside a Linux course: a first script, arguments, if, for, exit codes and a backup lab. This tutorial starts from zero again but goes much further, with 45 modules, 25 exercises and a capstone, and it covers the production topics M14 leaves out: strict-mode gotchas, trap, getopts, validation, logging, idempotency, ShellCheck and cron.
Should my scripts start with #!/bin/bash or #!/bin/sh?
Use #!/bin/bash (or #!/usr/bin/env bash) whenever the script uses Bash features such as [[ ]], arrays or ${v,,}, and run it with bash, not sh. On many Linux systems sh is a smaller shell such as dash, and those features fail there. Write for /bin/sh only when a script must run on systems without Bash, and then avoid Bash-only syntax.
Should every script use set -euo pipefail?
It is a good default for new scripts, and the capstone uses it. It is a safety net, not full error handling: set -e is ignored inside if conditions and on the left of && and OR lists, ((count++)) at zero stops the script, and local x=$(cmd) hides a failure. L4-M1 shows each case with its fix.
Why do some examples end with an error?
Nine scripts exit with a non-zero status on purpose, to show what exit, set -u, trap, input validation, a CI lint step and a failing health check look like. Each one is labelled with its exit status, and the full list is in "Before you start". Run echo $? after any of them to see the code.
Can I use these scripts at work?
They are teaching examples, written to run safely in a temporary folder. The production template in the cheat sheet and the capstone tool are good starting points. Before you use one on a real server, replace the offline stand-ins (the probe function instead of curl, the simulated cron runs instead of crontab) with the real commands shown in their comments, run ShellCheck, and test on a non-production machine first.
Continue
Practise it: keep the cheat sheet open, download all 162 scripts and test yourself with the quiz.
Related: Linux from Scratch · M14: Shell scripting · AI/ML roadmap
Learn live: Training · WhatsApp +91 70492 35525