← pushpjeet.com · TutorialsDownload the 162 scripts
Beginner to pro45 modules162 tested scriptsBash 4+

Bash Scripting Tutorial: Zero to Pro, with 162 Tested Scripts

Most server automation still starts as a Bash script: a backup before a release, a log clean-up at midnight, a health check that a monitoring system calls every few minutes. Writing one that works once is easy. Writing one that behaves the same way on the hundredth run, with an odd file name, an empty variable or a full disk, is the real skill.

This tutorial covers both. It has five levels and a capstone project. Each of its 45 modules explains one idea and then shows three examples (simple, medium, a bit harder). Every example lists the script, the exact command used to run it, and the output it produced. Each level ends with five practice exercises and worked solutions.

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:

  1. Write, run and debug scripts from the terminal, and explain why a script fails with Permission denied, command not found or $'\r'.
  2. Use variables, quoting, read and printf correctly, including file names that contain spaces.
  3. Make decisions with exit statuses, [ ], [[ ]], (( )) and case.
  4. Loop over lists, ranges, files and command output, and read a file line by line without losing data.
  5. Organise code into functions with local variables, process arguments, and work with indexed and associative arrays.
  6. Trim, replace and change the case of strings with parameter expansion, and control every stream with redirection.
  7. Write production-grade scripts: strict mode and its gotchas, trap cleanup, getopts flags, input validation, timestamped logs, mktemp, idempotent steps, tracing with set -x, ShellCheck, and cron-safe wrappers.
  8. 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 -p does 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 probe function in place of curl. The comment above it shows the curl line 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:

Take the Bash quiz (25 questions)

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#

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 with dos2unix file.sh or 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 with chmod +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.sh instead of ./file.sh and getting command not found.
  • Running chmod +x on the wrong file, or forgetting it, and getting Permission denied.
  • Running sh file.sh when the script uses Bash features. sh may be a smaller shell (like dash) and fail on [[ ]] or arrays. Use bash file.sh.
  • Using sudo to "fix" a permission error. You almost never need sudo to run your own script; you need chmod +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 \n at the end of a printf format, 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. Use printf.
  • Passing user data as the format: printf "$msg". If the text contains % it breaks. Use printf '%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 = 5 fails with count: command not found.
  • Writing $file_old when you meant ${file}_old. Bash looks for a variable called file_old, finds nothing and prints an empty string.
  • Using $ when assigning: $name=web01 is wrong; write name=web01.
  • Overwriting important environment variables like PATH or USER by 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 $variables and $(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 $file with file="my report.txt" tries to delete my and report.txt.
  • Using single quotes when you wanted expansion: echo 'Hello $name' prints $name literally.
  • 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.
  • -r keeps backslashes as typed. Always use it.
  • Give several variable names and read splits the line on spaces: the last variable gets whatever is left.
  • -s hides the typing (for passwords) and -t 10 gives 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 like C:\new loses 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 -p prompt 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.

Take the Level 0 quiz (5 questions)

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 an echo or 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 || bad and assuming it is an if-else. If good itself fails, bad runs too. Use a real if when 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. Use if false without brackets.
  • Writing while true without any break or 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 then or fi: you get syntax error near unexpected token.
  • Missing the semicolon: if (( x > 5 )) then must be if (( x > 5 )); then (or put then on the next line).
  • Using > inside [ ]: [ 10 > 9 ] creates a file called 9! Use -gt in [ ] 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 ))). else takes no condition; use elif for 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 >= 70 before >= 90 means 95 is labelled Warning, never Critical.
  • Writing else if (two words). Bash needs elif, or a whole new nested if ... 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 with command not found. You need [ "$a" = "$b" ].
  • Using == inside [ ] in scripts meant for sh. 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 in sh or dash. 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 (so 05 and 5 differ) and > is a redirection.
  • Comparing decimals: [ 2.5 -gt 2 ] gives integer 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: Yes is not yes. Lowercase first with ${answer,,}.
  • Leaving out spaces around ==: [[ $a==$b ]] is one non-empty word and is always true!
  • Using -eq for 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 -r and -w behave differently for root.

Common mistakes#

  • Forgetting quotes: [ -f $file ] breaks when the name has spaces or is empty.
  • Using -e when 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 echo after each hidden read -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.

Take the Level 1 quiz (5 questions)

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:

  • for repeats once for each item in a list.
  • while repeats while a condition is true.
  • until repeats 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 do or done.
  • 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 to 1 2 3 4 5; {0..20..5} counts in steps of 5.
  • Files: for f in *.log; do loops over matching file names (this is called globbing).
  • C-style: for (( i = 0; i < 5; i++ )); do gives 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. Use for (( i = 1; i <= n; i++ )) or seq 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 .log files exist, *.log stays as literal text. Check with [ -e "$f" ] || continue, or set shopt -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 sleep between 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 break to exit the script. It only leaves the loop; use exit to stop the script.
  • Inside nested loops, break leaves 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 continue in a while loop 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= stops read from trimming spaces at the start and end of the line. (IFS is the Internal Field Separator, the characters Bash splits on.)
  • -r keeps backslashes.
  • < file after done feeds 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, or IFS= when spaces matter.
  • Piping into the loop (cat f | while ...) and expecting variables to survive. Use done < file.
  • Calling a command inside the loop that also reads standard input (like ssh), which swallows the rest of the file. Use ssh -n or 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; add 2>&1 if 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. Use awk 'BEGIN{print 10/4}' for decimals.
  • Leading zeros: $(( 08 + 1 )) fails because 08 is read as an (invalid) octal number. Strip zeros or use 10#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.

Take the Level 2 quiz (5 questions)

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, not greet(Asha).
  • Confusing the script's $1 with the function's $1. Inside a function, $1 is the function's own first argument.

L3-M2: return vs echo, and local variables#

Functions have two ways to give something back:

  • return N sets the exit status (0 to 255). Use it for yes/no answers, so the function works directly in if.
  • echo prints 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, and return 300 wraps 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 whether local worked. Write local 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.

  • $0 is 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.
  • shift throws away $1 and moves the others down ($2 becomes $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 $10 and getting $1 followed 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 items a, b, and c.

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).

  • <<EOF expands $variables and $(commands) inside the text.
  • <<'EOF' (with quotes) keeps the text exactly as written, which is great for writing scripts or templates.
  • <<-EOF strips 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 EOF with spaces. It must be alone at the start of its line (or tab-indented with <<-).
  • Forgetting quotes on 'EOF' when writing scripts, so $1 is 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 ls error 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 > file and expecting both in the file. Redirections are read left to right: put > file first.
  • Using > when you meant >>, which wipes the log.
  • sort file > file empties the file before sort reads it. Write to a temp file, then move it.
  • Piping a command's errors: | carries only stdout. Use 2>&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 exit inside a function when you meant return. exit ends 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 trap solves 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.

Take the Level 3 quiz (5 questions)

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 -u it would have printed Restarting with 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 -e and assuming every error is now handled. Read the gotchas above.
  • Putting set -euo pipefail on 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 source into 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" EXIT expands $file NOW. Use single quotes or a function.
  • Calling exit without a code in an INT handler, so the script reports success. Use exit 130.
  • Setting a second trap ... EXIT later 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 $OPTARG is 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 getopts to 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 -eq before checking that the value is a number. You get a messy integer expression expected error.
  • Forgetting that numbers with leading zeros (08) are treated as octal in (( )). Force base 10 with 10#$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). Use YYYY-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 mv is 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 mkdir without -p in 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.sh traces the whole script without editing it.
  • set -x ... set +x traces just one section.
  • PS4 sets the prefix of each trace line. Adding ${LINENO} and ${FUNCNAME[0]} tells you where each line came from.
  • trap '...' ERR reports the line number of any failing command.
  • bash -n script.sh checks 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 95 because it returned non-zero. It would NOT fire for a failure inside an if condition or on the left of && / ||, because Bash treats those failures as handled.

Common mistakes#

  • Leaving set -x on 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/bash so 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 in date +\%F).
  • Overlapping runs of a slow job. Use a lock (flock -n or 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: probe sets a global variable code instead of using echo, because $(probe ...) would run in a subshell and the retry counter in calls would 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"/* with dir empty means /*!). Use set -u and 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.

Take the Level 4 quiz (5 questions)

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): -w warning %, -c critical %, -s service list, -f metrics file (demo/test mode), -o report file, -l log file, -q quiet, -v verbose, -V version, -h help.
  • 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 main function, and ShellCheck-clean code.
  • Testability: -f FILE reads 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 array M with 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 EXIT and 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 -j to print JSON instead of a table (hint: build it with printf, keeping the same rows).
  • Add an inode check (df -Pi /).
  • Add a -m option 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)

ShellCheck

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

← All tutorials

Newsletter

Liked this? Get the next tutorial by email

New tutorials, labs and cheat sheets, plus AWS batch dates and Oracle tips if you want them. No spam, unsubscribe anytime.

I’m interested in

Double opt-in: you’ll get a confirmation email first. Privacy policy