🎓 Bash Programming Tutorial

Bash programming tutorial / survival guide

Video: Bash tutorial (Shell Scripting video)
Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
Bash Programming Tutorial: from your first script to real tools
#!/usr/bin/env bash

This tutorial teaches you to WRITE bash programs, step by step. Each lesson has a short explanation, code you can type
in and run, what you should see, and a small exercise (answers are near the end). By the end you will build real
tools: a backup script, a log analyzer and a system health check. You need a Linux (or macOS or WSL) terminal and a
text editor (nano, vim, or VS Code). No programming experience is needed.
For a quick reference of prompt usage, shortcuts and syntax, see the Bash cheat sheet under Command Line Tips.

How to use this tutorial

1. Make a practice folder:    mkdir -p ~/bash-practice && cd ~/bash-practice
2. Type the examples yourself instead of pasting them. Typing teaches the syntax
3. Save each script as a file, make it executable, and run it:    chmod +x name.sh    then    ./name.sh
4. Try to break things on purpose. Change a value, delete a quote, and see what error appears
5. Do the exercise at the end of each lesson before you look at the answers
6. Only run scripts on fake data until you understand every line (see the AI-Assisted Shell Scripting guide for safety habits)

Check your setup

bash --version                   # you want 4.0 or newer. (macOS ships 3.2: install a newer one with: brew install bash)
which bash                       # usually /bin/bash or /usr/bin/bash
echo $SHELL                      # your login shell (any shell can RUN bash scripts)
nano hello.sh                    # or any editor you like

LESSON 1: YOUR FIRST SCRIPT

A script is a text file of commands that bash runs from top to bottom.
Create hello.sh:
#!/usr/bin/env bash
# My first script
echo "Hello, World!"
echo "Today is $(date +%A)."
Make it executable and run it:
chmod +x hello.sh
./hello.sh
Output:
Hello, World!
Today is Saturday.
What you learned:
#!/usr/bin/env bash      The "shebang": the first line tells the system which program runs the file. env finds bash on your PATH.
                         (#!/bin/bash also works if bash lives there.) It must be the very first line, with no space before #!
# a comment              Everything after # on a line is ignored. Use comments to explain WHY, not what
chmod +x file            Gives the file permission to be run. Without it: "Permission denied"
./hello.sh               The ./ means "in this folder". Commands are looked up on the PATH, and your folder is not on it
bash hello.sh            You can also run a script without chmod. Then the shebang is ignored
Exercise 1: Write a script that prints your name and the current directory (use pwd).

LESSON 2: VARIABLES

name="Alice"                     # NO spaces around the =
age=30
echo "Name: $name, Age: $age"
echo "Next year you will be $((age + 1))"
Rules:
- Assign with name=value (no spaces!). "name = value" tries to run a command called name
- Read with $name, and quote it: "$name"
- Use ${name} when text follows: echo "${name}_backup"
- Names use letters, digits and underscores, and cannot start with a digit. Convention: lowercase for your variables, UPPERCASE for environment variables
- Everything is text unless you use arithmetic: $((...)) or (( ))
Capture a command's output with $( ):
today=$(date +%F)
files=$(ls | wc -l)
echo "$today: $files files here"
Environment variables (programs you start can see them):
export EDITOR=vim
echo "$HOME  $USER  $PWD  $PATH"
Special variables you will use constantly:
$0 script name   $1 $2 ... arguments   $# how many arguments   $@ all arguments   $? exit status of the last command   $$ this process's ID
Exercise 2: Store your favorite number in a variable and print double its value.

LESSON 3: QUOTING (the most important lesson)

file="my report.txt"
touch $file            # WRONG: creates two files, "my" and "report.txt"
touch "$file"          # RIGHT: creates one file
'single quotes'        # everything literal: echo 'Cost: $5'  prints  Cost: $5
"double quotes"        # variables and $(...) expand: echo "Cost: $price"
\$                     # escape a single character: echo "Cost: \$5"
Golden rule: put double quotes around every variable and every $( ): "$var", "$(cmd)", "$@".
Why: unquoted values are split on spaces and expanded as wildcards. That is how a script deletes the wrong files.
Try it:
files="a b"
for f in $files; do echo "[$f]"; done       # 2 lines: [a] and [b]
for f in "$files"; do echo "[$f]"; done     # 1 line:  [a b]
Exercise 3: Make a directory called "my notes" (with a space) using a variable, and remove it again safely.

LESSON 4: INPUT: ARGUMENTS AND read

Script arguments (greet.sh):
#!/usr/bin/env bash
echo "Script: $0"
echo "First argument: $1"
echo "Second argument: ${2:-none given}"
echo "Count: $#"
echo "All: $@"
./greet.sh Alice Bob        # First argument: Alice, Second: Bob, Count: 2
Check for required arguments:
if [[ $# -lt 1 ]]; then
    echo "Usage: $0 NAME" >&2       # >&2 sends the message to stderr
    exit 1
fi
Ask the user with read:
read -r -p "Your name: " name
echo "Hello, $name"
read -r -s -p "Password: " pw; echo       # -s hides what you type
read -r -t 5 -p "Quick, answer in 5 seconds: " ans || echo "too slow"
Default values with parameter expansion:
name="${1:-World}"                 # use $1, or "World" if empty or missing
dir="${2:?Please give a directory}"   # stop with a message if missing
Exercise 4: Write add.sh that takes two numbers as arguments and prints their sum. Print a usage message if they are missing.

LESSON 5: MAKING DECISIONS (if, tests, case)

if [[ -f "$file" ]]; then
    echo "$file exists"
elif [[ -d "$file" ]]; then
    echo "$file is a directory"
else
    echo "$file not found"
fi
Tests go inside [[ ]] (spaces required inside the brackets). Cheat list:
Files       -e exists   -f regular file   -d directory   -r readable   -w writable   -x executable   -s not empty   -L symlink
Strings     -z empty    -n not empty      ==  equal      !=  not equal      =~  regex match      < >  sort order
Numbers     -eq  -ne  -lt  -le  -gt  -ge         (or use (( )) with normal math symbols)
Combine     &&  and     ||  or     !  not
Numbers with (( )):
if (( count > 10 && count <= 20 )); then echo "medium"; fi
Pattern and regex:
if [[ $file == *.txt ]]; then echo "text file"; fi
if [[ $email =~ ^[^@]+@[^@]+\.[a-z]+$ ]]; then echo "looks like an email"; fi
You can also test the exit status of any command:
if grep -q "error" app.log; then echo "errors found"; fi
if ! ping -c1 -W1 example.com >/dev/null 2>&1; then echo "offline"; fi
Short forms:
[[ -d backups ]] || mkdir backups          # create it only if missing
[[ -f config ]] && source config           # load it only if present
case for many choices:
case "$answer" in
    y|Y|yes) echo "Proceeding" ;;
    n|N|no)  echo "Cancelled"; exit 0 ;;
    "")      echo "You must type something" ;;
    *)       echo "Unknown answer: $answer" ;;
esac
Prefer [[ ]] in bash. The older single [ ] (and "test") work everywhere but need more quoting care.
Exercise 5: Ask for a number and print "positive", "negative" or "zero".

LESSON 6: LOOPS

for i in 1 2 3; do echo "Number $i"; done
for i in {1..5}; do echo "$i"; done                  # a range
for ((i = 0; i < 5; i++)); do echo "$i"; done         # C-style
for f in *.txt; do echo "Processing $f"; done         # over files (the safe way: a glob, quoted inside)
for arg in "$@"; do echo "Argument: $arg"; done       # over script arguments
while and until:
count=1
while (( count <= 3 )); do
    echo "Count is $count"
    ((count++))
done
until ping -c1 -W1 example.com >/dev/null 2>&1; do echo "waiting..."; sleep 2; done
Read a file line by line (memorize this pattern):
while IFS= read -r line; do
    echo "Line: $line"
done < input.txt
Split a CSV line into fields:
while IFS=, read -r name age city; do
    echo "$name is $age and lives in $city"
done < people.csv
Control the loop:  break (leave)   continue (skip to the next round)   break 2 (leave two loops)
DON'T:  for f in $(ls)    it breaks on names with spaces. Use  for f in *  instead.
Avoid this trap: a loop at the end of a PIPE runs in a subshell, so variables set inside are lost:
count=0; cat file | while read -r l; do ((count++)); done; echo "$count"     # prints 0 !
count=0; while read -r l; do ((count++)); done < file; echo "$count"          # correct
Exercise 6: Print a multiplication table (1 to 10) for a number given as an argument.

LESSON 7: FUNCTIONS

Functions group code you use again and again.
greet() {
    local name="${1:-World}"        # "local" keeps the variable inside the function
    echo "Hello, $name!"
}
greet                               # Hello, World!
greet Alice                         # Hello, Alice!
Arguments: inside a function $1, $2, $@ and $# refer to the FUNCTION's own arguments.
Returning values (two channels):
1. Status code (0-255) with return: for success or failure. Test it with if
is_number() { [[ $1 =~ ^-?[0-9]+$ ]]; }          # the function's status is the test's status
if is_number "42"; then echo "yes"; fi
2. Output with echo, captured with $( ): for actual results
double() { echo $(( $1 * 2 )); }
result=$(double 21)                 # result is 42
Put shared functions in a library file and load it:  source ./lib.sh    (or  . ./lib.sh)
A logging helper you will reuse:
log()  { printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "${*:2}" >&2; }
log INFO "Starting"
log ERROR "Something broke"
die()  { log ERROR "$*"; exit 1; }
Rules of thumb: small functions that do ONE thing, always use local, name them with verbs, and put main logic in a main() at the bottom.
Exercise 7: Write a function that takes a file name and prints "exists" or "missing", then call it for three files.

LESSON 8: ARRAYS

fruits=(apple banana "red grape")
echo "${fruits[0]}"               # apple (indexes start at 0)
echo "${fruits[@]}"               # all items:   ALWAYS quote as "${fruits[@]}" so each item stays whole
echo "${#fruits[@]}"              # 3 (how many)
fruits+=(mango)                   # append
for f in "${fruits[@]}"; do echo "- $f"; done
Read a file into an array, one line per item:
mapfile -t lines < input.txt
echo "First line: ${lines[0]}"
Associative arrays (key and value pairs, bash 4+):
declare -A ports=([ssh]=22 [http]=80 [https]=443)
ports[smtp]=25
for name in "${!ports[@]}"; do echo "$name -> ${ports[$name]}"; done
echo "${ports[ssh]}"
[[ -v ports[ftp] ]] || echo "no ftp entry"
Use arrays to build command arguments safely (no quoting problems):
args=(-l -a --color=auto)
[[ -n $dir ]] && args+=("$dir")
ls "${args[@]}"
Exercise 8: Store five numbers in an array and print their sum.

LESSON 9: WORKING WITH TEXT AND STRINGS

Built-in string tools (no external commands needed):
path="/var/log/app.log"
echo "${#path}"                   # length
echo "${path##*/}"                # app.log     (remove everything up to the last /)
echo "${path%/*}"                 # /var/log    (remove the last / and after)
echo "${path%.log}"               # /var/log/app
echo "${path/var/tmp}"            # replace the first match      ${path//a/b} replaces all
echo "${path^^}"                  # UPPERCASE     ${path,,} lowercase
echo "${path:5:3}"                # log   (from offset 5, length 3)
Format output with printf (more reliable than echo):
printf '%-10s %5d\n' "apples" 42          # left-aligned text, right-aligned number
printf '%s\n' "${array[@]}"               # one item per line
printf '%.2f\n' 3.14159                   # 3.14
Compare and search:
[[ $str == Hello* ]]        [[ $str == *world ]]        [[ $str =~ [0-9]+ ]] && echo "${BASH_REMATCH[0]}"
Heredocs create multi-line text:
cat > report.txt <<EOF
Report for $USER
Generated: $(date)
EOF
cat <<'EOF'      # quoted marker: no expansion, everything literal
The price is $5
EOF
The classic text tools (each reads text and writes text, so you chain them with pipes):
grep -i "error" app.log                   # filter lines          grep -c   count    grep -v   exclude    grep -E   extended regex
cut -d, -f2 data.csv                      # pick a column         cut -c1-10   pick characters
sort | uniq -c | sort -rn                 # count and rank things
awk '{print $1}' access.log               # pick a field          awk '{sum += $3} END {print sum}'   add up a column
sed 's/old/new/g' file                    # replace text          sed -n '5,10p' file    print lines 5 to 10
tr 'a-z' 'A-Z'                            # translate characters  tr -d '\r'    delete carriage returns
wc -l file                                # count lines           head -n 5    tail -n 5    tail -f (follow)
Bash is best at connecting these tools together, so learn them along with the language.
Exercise 9: Given a file path in a variable, print its folder, file name and extension separately.

LESSON 10: EXIT CODES, ERRORS AND SAFETY

Every command returns a number: 0 means success, anything else means failure. Read the last one with $?.
ls /nonexistent; echo "status: $?"          # status: 2
Make your own script return one:  exit 0 (ok)   exit 1 (error)   exit 2 (bad usage)
Check the important commands:
if ! cp "$src" "$dst"; then
    echo "Copy failed" >&2
    exit 1
fi
cd "$dir" || { echo "Cannot enter $dir" >&2; exit 1; }
Strict mode (put near the top of most scripts):
set -Eeuo pipefail
-e   exit when a command fails         -u   error on an unset variable (catches typos)
-o pipefail   a pipeline fails if ANY step fails       -E   let ERR traps work inside functions
Know its limits: -e does not fire inside if conditions, after && or ||, or in some pipelines. Still check critical commands yourself.
Allow a failure on purpose:  grep -q foo file || true          Or capture it:  if ! out=$(cmd); then ...; fi
Clean up with trap (runs however the script ends):
tmpdir=$(mktemp -d)
trap 'rm -rf -- "$tmpdir"' EXIT
trap 'echo "Interrupted" >&2; exit 130' INT TERM
trap 'echo "Error on line $LINENO" >&2' ERR
Safety habits:
- Quote every variable            - Use mktemp for temp files, never fixed names in /tmp
- ${dir:?} before rm -rf "${dir:?}"/*     so an empty variable cannot delete /
- Use -- before file names:  rm -- "$f"  (a name starting with - is not an option)
- Test destructive commands with echo first:  echo rm -- "$f"
- Add a --dry-run mode to anything that changes data
- Validate input (is it a number? does the file exist?) before using it
Exercise 10: Write a script that copies a file to a backup folder and exits with an error message if the source does not exist.

LESSON 11: REDIRECTION, PIPES AND FILES INSIDE SCRIPTS

cmd > out.txt                  # stdout to a file (overwrite)          cmd >> out.txt    append
cmd 2> err.txt                 # stderr to a file
cmd > all.txt 2>&1             # both to one file (or: cmd &> all.txt)
cmd > /dev/null 2>&1           # throw everything away
cmd < input.txt                # read stdin from a file
cmd1 | cmd2                    # pipe: stdout of cmd1 becomes stdin of cmd2
cmd | tee log.txt              # show it AND save it
diff <(sort a.txt) <(sort b.txt)     # process substitution: use command output as a file
Log everything a script prints (put near the top):
exec > >(tee -a "run.log") 2>&1
Send messages to the right place:  results go to stdout, warnings and errors go to stderr (echo "..." >&2). Then users can pipe your output safely.
Write a file safely (no half-written file if the script stops):
tmp=$(mktemp); generate > "$tmp" && mv -- "$tmp" final.txt
Lock a script so only one copy runs:
exec 9>/tmp/myscript.lock; flock -n 9 || { echo "Already running" >&2; exit 1; }
Read input from a file OR a pipe:  if [[ -t 0 ]]; then echo "interactive"; else echo "input is piped"; fi
Exercise 11: Count how many lines of a log file contain "ERROR" and write the result to a report file.

LESSON 12: OPTIONS AND USAGE MESSAGES (getopts)

Real tools take options like -v and -o file. getopts parses them:
#!/usr/bin/env bash
set -Eeuo pipefail
usage() { echo "Usage: ${0##*/} [-v] [-n COUNT] [-o FILE] input" >&2; exit 2; }
verbose=0; count=10; out=""
while getopts ":vn:o:h" opt; do
    case "$opt" in
        v) verbose=1 ;;
        n) count="$OPTARG" ;;
        o) out="$OPTARG" ;;
        h) usage ;;
        :) echo "Option -$OPTARG needs a value" >&2; usage ;;
        \?) echo "Unknown option -$OPTARG" >&2; usage ;;
    esac
done
shift $((OPTIND - 1))                 # remove the parsed options; $1 is now the first plain argument
[[ $# -ge 1 ]] || usage
input="$1"
(( verbose )) && echo "Reading $input, count=$count" >&2
Run:  ./tool.sh -v -n 5 data.txt        In ":vn:o:h" a colon AFTER a letter means that option takes a value, and the leading colon turns on quiet error handling.
getopts handles only single-letter options. For --long-options write a while/case loop over "$@":
while [[ $# -gt 0 ]]; do
    case "$1" in
        --dry-run) dry=1; shift ;;
        --output)  out="$2"; shift 2 ;;
        --output=*) out="${1#*=}"; shift ;;
        --) shift; break ;;
        -*) echo "Unknown option: $1" >&2; exit 2 ;;
        *)  break ;;
    esac
done
Exercise 12: Add a -v (verbose) flag and a -n (number of lines) option to a script that prints the first N lines of a file.

LESSON 13: DEBUGGING

bash -n script.sh                 # syntax check only: runs nothing
bash -x script.sh                 # trace: prints every command with its expanded values, prefixed by +
set -x ... set +x                 # trace only a section of the script
PS4='+ ${BASH_SOURCE##*/}:${LINENO}: '     # a more helpful trace prefix (file and line number)
shellcheck script.sh              # a linter that finds real bugs: quoting, wrong tests, unsafe patterns  (sudo apt install shellcheck)
declare -p var                    # show exactly what a variable holds
echo "DEBUG: var=[$var]" >&2      # brackets reveal hidden spaces
trap 'echo "failed: $BASH_COMMAND (line $LINENO)" >&2' ERR
Method: 1) read the FIRST error message   2) find the line number   3) add echo lines or set -x around it   4) shrink the problem to a few lines   5) fix ONE thing at a time.
Common errors and their meaning:
"command not found"               Typo, not installed, or a space around = in an assignment (name = value)
"Permission denied"               Missing chmod +x, or you have no access to the file
"bad interpreter" / "^M"          Windows line endings: dos2unix script.sh
"unbound variable"                set -u caught a variable that was never set (a typo, or a missing argument)
"unexpected end of file"          A missing fi, done, esac or closing quote or brace
"syntax error near unexpected token"   A missing space, semicolon, or a keyword in the wrong place
"[: too many arguments"           An unquoted variable with spaces inside [ ]. Quote it, or use [[ ]]
"integer expression expected"     You compared text with -eq. Check the value
"ambiguous redirect"              An unquoted variable in a redirect, holding spaces or nothing
Exercise 13: The script below has 3 bugs. Find and fix them (answers at the end):
    #!/usr/bin/env bash
    name = "Alice"
    if [ $name == "Alice" ]
    then echo "Hi Alice"
    for f in $(ls *.txt); do echo $f; done

LESSON 14: BEST PRACTICES

Structure
#!/usr/bin/env bash
# name.sh: one line saying what it does
set -Eeuo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME="${0##*/}"

usage() { ... }
log() { ... }
main() {
    parse arguments
    validate input
    do the work
}
main "$@"
Style
- Use [[ ]] and (( )), $( ) instead of backticks, and lowercase names for your variables
- Indent with 4 spaces (or 2), keep lines short, and add a blank line between logical sections
- Comment the WHY. Give functions and variables clear names. Prefer readable over clever
- One function, one job. Put reusable code in a library file and source it
- Use long options in scripts (curl --silent) so they explain themselves
Portability and honesty
- Use #!/usr/bin/env bash when you use bash features. Use #!/bin/sh only if you stick to POSIX (no [[ ]], arrays or ${var//})
- GNU vs BSD tools differ (sed -i, date, stat, readlink). Test on the systems you support
- Check that required programs exist:   for cmd in curl jq; do command -v "$cmd" >/dev/null || { echo "Need $cmd" >&2; exit 1; }; done
Security
- Never build commands from untrusted input, never use eval on data you did not write, and never source a file you do not trust
- Keep secrets out of scripts and out of the process list (do not put passwords on command lines). Use environment variables or a secrets manager
- Run with the least privilege. Do not run whole scripts as root. Use sudo only for the steps that need it
- Set a safe umask for files you create (umask 077 for private files), and use mktemp
When to stop using bash: if you need complex data structures, lots of JSON or API work, unit tests or more than a few hundred lines, switch to Python.
Lint every script with shellcheck, and read the AI-Assisted Shell Scripting guide if an AI helped you write it.

PROJECT 1: A SAFE BACKUP SCRIPT

Backs up a folder to a dated tar.gz, keeps the newest N, and supports a dry run.
#!/usr/bin/env bash
# backup.sh: archive a folder with a date in the name, and keep the newest N archives
set -Eeuo pipefail

usage() { echo "Usage: ${0##*/} [-n] [-k KEEP] SOURCE_DIR DEST_DIR" >&2; exit 2; }
log()   { printf '%s %s\n' "$(date '+%F %T')" "$*" >&2; }
die()   { log "ERROR: $*"; exit 1; }

dry=0; keep=7
while getopts ":nk:h" opt; do
    case "$opt" in
        n) dry=1 ;;
        k) keep="$OPTARG" ;;
        h) usage ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))
[[ $# -eq 2 ]] || usage
src="$1"; dest="$2"

[[ -d "$src" ]]  || die "Source is not a directory: $src"
[[ "$keep" =~ ^[0-9]+$ ]] || die "KEEP must be a number"
mkdir -p -- "$dest"

name="$(basename "$(realpath "$src")")"
archive="$dest/${name}-$(date +%F-%H%M%S).tar.gz"

if (( dry )); then
    log "DRY RUN: would create $archive"
else
    log "Creating $archive"
    tar -czf "$archive" -C "$(dirname "$(realpath "$src")")" "$name"
    log "Done ($(du -h "$archive" | cut -f1))"
fi

# remove old archives, newest first, skipping the first $keep
mapfile -t old < <(ls -1t "$dest/${name}"-*.tar.gz 2>/dev/null | tail -n +$((keep + 1)))
if (( ${#old[@]} )); then
    for f in "${old[@]}"; do
        if (( dry )); then log "DRY RUN: would delete $f"; else log "Deleting $f"; rm -- "$f"; fi
    done
fi
Try it:  mkdir -p ~/demo/data && touch ~/demo/data/a.txt     ./backup.sh -n ~/demo/data ~/demo/backups     then without -n
Ideas to extend it: exclude patterns, checksum the archive, email on failure, run from cron (see the crontab line in the Python sheet).

PROJECT 2: A LOG ANALYZER

Summarizes a web server access log: top IP addresses, status codes and busiest hours.
#!/usr/bin/env bash
# logstats.sh: quick statistics from an access log (combined format)
set -Eeuo pipefail
log_file="${1:?Usage: ${0##*/} ACCESS_LOG [TOP_N]}"
top="${2:-5}"
[[ -r "$log_file" ]] || { echo "Cannot read $log_file" >&2; exit 1; }

section() { printf '\n== %s ==\n' "$1"; }

section "Total requests"
wc -l < "$log_file"

section "Top $top client IPs"
awk '{print $1}' "$log_file" | sort | uniq -c | sort -rn | head -n "$top"

section "Status codes"
awk '{print $9}' "$log_file" | sort | uniq -c | sort -rn

section "Requests per hour"
awk -F'[' '{split($2, a, ":"); print a[2]}' "$log_file" | sort | uniq -c

section "Top $top requested paths"
awk '{print $7}' "$log_file" | sort | uniq -c | sort -rn | head -n "$top"

section "Server errors (5xx)"
awk '$9 ~ /^5/' "$log_file" | wc -l
Try it with a made-up file:  printf '1.2.3.4 - - [26/Sep/2026:10:15:00 +0000] "GET / HTTP/1.1" 200 512\n' > access.log
Ideas: add a --since option, colorize the output, output JSON, or turn it into a function library.

PROJECT 3: A SYSTEM HEALTH CHECK

Warns when disk, memory or load is high, and when a service is down. Exit code 0 = all fine, 1 = something is wrong (handy for cron and monitoring).
#!/usr/bin/env bash
# healthcheck.sh: simple system checks with thresholds
set -Eeuo pipefail

DISK_LIMIT=85         # percent
MEM_LIMIT=90          # percent
LOAD_LIMIT=4          # 1 minute load average
SERVICES=(ssh cron)   # systemd services that must be running

problems=0
warn() { echo "WARN: $*"; problems=$((problems + 1)); }
ok()   { echo "OK:   $*"; }

# disk: every real mounted filesystem
while read -r use mount; do
    use="${use%\%}"
    if (( use >= DISK_LIMIT )); then warn "disk $mount is ${use}% full"; else ok "disk $mount ${use}%"; fi
done < <(df -P -x tmpfs -x devtmpfs | awk 'NR>1 {print $5, $6}')

# memory
mem_used=$(free | awk '/^Mem:/ {printf "%d", $3 / $2 * 100}')
if (( mem_used >= MEM_LIMIT )); then warn "memory ${mem_used}% used"; else ok "memory ${mem_used}%"; fi

# load
load=$(cut -d' ' -f1 /proc/loadavg)
if awk -v l="$load" -v m="$LOAD_LIMIT" 'BEGIN {exit !(l >= m)}'; then warn "load average $load"; else ok "load average $load"; fi

# services
for svc in "${SERVICES[@]}"; do
    if systemctl is-active --quiet "$svc"; then ok "service $svc running"; else warn "service $svc NOT running"; fi
done

echo
if (( problems )); then echo "$problems problem(s) found"; exit 1; fi
echo "All checks passed"
Run from cron every 10 minutes and mail yourself the output when the exit code is 1:  */10 * * * * /opt/scripts/healthcheck.sh || echo "health problem" | mail -s "Server alert" you@example.com
Ideas: check open ports with ss, certificate expiry with openssl, add a --json flag, send alerts to a chat webhook with curl.

PROJECT 4: A MENU-DRIVEN TOOL

#!/usr/bin/env bash
set -Eeuo pipefail
show_disk()   { df -h | head -n 5; }
show_memory() { free -h; }
show_top()    { ps aux --sort=-%cpu | head -n 6; }

PS3="Choose an option: "
select choice in "Disk usage" "Memory" "Top CPU processes" "Quit"; do
    case "$choice" in
        "Disk usage")        show_disk ;;
        "Memory")            show_memory ;;
        "Top CPU processes") show_top ;;
        "Quit")              echo "Bye"; break ;;
        *)                   echo "Invalid choice" ;;
    esac
done
select prints a numbered menu, reads a number, and sets $choice to the matching text.

EXERCISE ANSWERS

Exercise 1
    #!/usr/bin/env bash
    echo "My name is Alice"
    echo "I am in: $(pwd)"
Exercise 2
    fav=7
    echo "Double: $((fav * 2))"
Exercise 3
    dir="my notes"
    mkdir -p -- "$dir"
    rmdir -- "$dir"
Exercise 4 (add.sh)
    #!/usr/bin/env bash
    if [[ $# -ne 2 ]]; then echo "Usage: $0 NUM1 NUM2" >&2; exit 1; fi
    echo $(( $1 + $2 ))
Exercise 5
    read -r -p "Number: " n
    if (( n > 0 )); then echo "positive"; elif (( n < 0 )); then echo "negative"; else echo "zero"; fi
Exercise 6
    n="${1:?Usage: $0 NUMBER}"
    for i in {1..10}; do echo "$n x $i = $((n * i))"; done
Exercise 7
    check() { if [[ -e $1 ]]; then echo "$1 exists"; else echo "$1 missing"; fi; }
    for f in /etc/hosts /nonexistent "$HOME"; do check "$f"; done
Exercise 8
    nums=(4 8 15 16 23); sum=0
    for n in "${nums[@]}"; do ((sum += n)); done
    echo "Sum: $sum"
Exercise 9
    p="/var/log/app.tar.gz"
    echo "Folder: ${p%/*}"; file="${p##*/}"; echo "File: $file"; echo "Extension: ${file#*.}"      # tar.gz  (use ${file##*.} for just gz)
Exercise 10
    src="${1:?Usage: $0 FILE}"; mkdir -p backups
    [[ -f $src ]] || { echo "Missing: $src" >&2; exit 1; }
    cp -- "$src" backups/
Exercise 11
    count=$(grep -c "ERROR" app.log || true)
    echo "ERROR lines: $count" > report.txt
Exercise 12
    verbose=0; n=10
    while getopts ":vn:" o; do case $o in v) verbose=1;; n) n=$OPTARG;; *) echo "bad option" >&2; exit 2;; esac; done
    shift $((OPTIND - 1)); f="${1:?need a file}"
    (( verbose )) && echo "Showing $n lines of $f" >&2
    head -n "$n" -- "$f"
Exercise 13 (fixed)
    #!/usr/bin/env bash
    name="Alice"                              # bug 1: no spaces around =  (name = "Alice" runs a command called "name")
    if [[ $name == "Alice" ]]; then           # bug 2: unquoted variable in [ ], and == is a bash feature of [[ ]]
        echo "Hi Alice"
    fi                                        # bug 3: the if block was never closed with fi
    for f in *.txt; do echo "$f"; done        # bonus: use a glob instead of $(ls), and quote "$f"

QUICK QUIZ (answers below)

1. What does the first line "#!/usr/bin/env bash" do?
2. Why is  name = "x"  an error, and what is the fix?
3. What is the difference between 'single' and "double" quotes?
4. What does  set -euo pipefail  do?
5. Why should you avoid  for f in $(ls) ?
6. How do you read a file line by line safely?
7. How do you return a value from a function?
8. What does  trap 'cleanup' EXIT  do?
Answers: 1) tells the system to run the file with bash. 2) Spaces make bash run a command called name: write name="x". 3) Single quotes are literal, double
quotes expand variables and $(...). 4) Stop on errors, on unset variables, and on failed pipeline steps. 5) It splits names on spaces: use a glob.
6) while IFS= read -r line; do ...; done < file. 7) Print it with echo and capture with $( ), or set a status with return. 8) Runs cleanup when the script ends, however it ends.

Where to go next
- Read the Bash cheat sheet (Command Line Tips) for shortcuts, history, parameter expansion tables and one-liners
- Study the AI-Assisted Shell Scripting guide for a safe review process and dry-run habits
- Learn awk, sed, grep and find (Command Line Tips) to power your scripts
- Read man bash (search with /), and the shellcheck wiki, which explains each warning
- Practice on real chores: backups, log cleanup, user reports, deployments. Keep every script in git
- When a script outgrows bash, move to Python (see the Python sheet)

Tips

    Type the examples, break them on purpose, and fix them. That is how scripting really sticks.

    Quote every variable. It solves more script bugs than anything else in this tutorial.

    Start each script with the shebang and set -Eeuo pipefail, and add a trap for cleanup.

    Check exit codes for commands that matter, and send errors to stderr with >&2.

    Test on fake data first and add a --dry-run mode before a script deletes or changes anything.

    Use functions, local variables and a main() so scripts stay readable as they grow.

    Run shellcheck and bash -x when something is wrong. Read the first error message first.

    Keep scripts in git, and write a usage message. Future you will thank present you.

    Know when to stop: if a script needs real data structures, JSON or APIs, write it in Python instead.