Module 3 — Shell Scripting

Turn the Linux commands you know into automation: scripts, variables, I/O and exit codes, conditionals, loops, functions, and writing robust Bash.

Lesson: Your First Bash Scripts

What you'll learn

By the end you can turn a sequence of commands you already type by hand into a small, repeatable script you run with one command.

The lesson

A shell script is just a plain text file containing the same commands you would type into your terminal, saved so you can run them all at once. Instead of typing five commands every morning, you put them in a file and run that file. That is the whole idea of automation: do it once, save it, reuse it forever.

You will write and run every script in this module on the Jumpbox (10.100.100.254, Ubuntu, user ubuntu). Log in there first.

1. The shebang line

The very first line of a script should be a shebang (pronounced "shuh-bang"). It tells the operating system which program should interpret the file:

#!/usr/bin/env bash

The #! characters are the shebang marker. /usr/bin/env bash means "find bash on the system's PATH and run this file with it." This is more portable than hard-coding #!/bin/bash, because Bash lives in different places on different systems, but env is almost always at /usr/bin/env.

Create your first script with a text editor like nano:

nano hello.sh

Type this in:

#!/usr/bin/env bash
echo "Hello from the Jumpbox!"

Save and exit (Ctrl+O, Enter, Ctrl+X in nano).

2. Making it executable and running it

A fresh file is not executable yet. Check its permissions:

ls -l hello.sh
# -rw-rw-r-- 1 ubuntu ubuntu 50 Jun  1 10:00 hello.sh

There is no x (execute) bit. Add it with chmod ("change mode"):

chmod +x hello.sh
ls -l hello.sh
# -rwxrwxr-x 1 ubuntu ubuntu 50 Jun  1 10:00 hello.sh

Now there are three ways the script can run. Here is what happens in each case:

  ./hello.sh        -> kernel reads the shebang, runs: bash hello.sh
                       (REQUIRES the execute bit)

  bash hello.sh     -> you explicitly hand the file to bash
                       (shebang ignored, execute bit NOT needed)

  hello.sh          -> "command not found" UNLESS the dir is on $PATH

Run it the normal way:

./hello.sh
# Hello from the Jumpbox!

Why the ./? The current directory is not on your PATH (the list of folders the shell searches for commands), for safety. So you tell the shell exactly where the file is: . means "here", and /hello.sh is the file in here.

bash hello.sh is handy while debugging because it does not need the execute bit and lets you add flags like bash -x hello.sh to trace execution.

3. Variables

A variable is a named box that holds a value. You assign with = and no spaces around it:

name="Ada"
count=5

name = "Ada" (with spaces) is an error — the shell would try to run a command called name. To read a variable back, put a $ in front:

echo "$name"      # Ada
echo "$count"     # 5

Variable names are case-sensitive and conventionally lowercase for your own variables, UPPERCASE for environment variables like HOME or PATH.

4. Quoting basics

Quoting is the single biggest source of bugs for beginners, so learn it now.

See the difference:

greeting="hello   world"
echo "$greeting"   # hello   world   (spaces preserved)
echo $greeting     # hello world     (collapsed to one space! word-split)
echo '$greeting'   # $greeting       (single quotes: literal)

The rule of thumb: always wrap your variable references in double quotes unless you have a specific reason not to. echo "$name", not echo $name.

5. Command substitution $( )

You can capture the output of a command and store it in a variable using $( ):

today=$(date +%F)
echo "Today is $today"      # Today is 2026-06-01

files=$(ls | wc -l)
echo "There are $files items here"

$(date +%F) runs date +%F, grabs what it prints, and substitutes it in place. You may see an older backtick syntax `date` — it does the same thing but is harder to read and cannot be nested cleanly. Prefer $( ).

Putting it together, a slightly more useful script:

#!/usr/bin/env bash
user=$(whoami)
host=$(hostname)
now=$(date +"%F %T")
echo "Report by $user on $host at $now"

Save as report.sh, chmod +x report.sh, run ./report.sh. You just automated a little status line — the seed of every monitoring script you will write later.

6. Comments and habits

Anything after a # (except the shebang) is a comment the shell ignores. Use comments to explain why, not what:

# Use UTC so logs from all hosts line up
now=$(date -u +"%F %T")

Because you will commit these scripts to Git in a later module, build clean habits now: one shebang line, comments at the top saying what the script does, lowercase variable names, and quotes around variables.

Dig deeper

Search terms

Check yourself

  1. What does the shebang line #!/usr/bin/env bash do, and why is env used instead of a hard-coded path?
  2. Why must you type ./hello.sh instead of just hello.sh, and when can you skip the ./?
  3. What is wrong with the line name = "Ada", and how do you fix it?
  4. What is the practical difference between echo "$greeting" and echo $greeting?
  5. Write a line that stores today's date into a variable called today and prints it.

Lesson: Streams, Redirection & Exit Codes

What you'll learn

By the end you can control where a script's input comes from and where its output and errors go — the foundation of logging and error handling.

The lesson

Every command on Linux is wired up to three streams — think of them as three pipes attached to the program. You will use these constantly in scripts to capture results, separate errors, and chain tools together.

You will run all of this on the Jumpbox (10.100.100.254, user ubuntu).

1. The three standard streams

                +---------------------+
   stdin (0) -->|     your command    |--> stdout (1)  normal output
                |                     |--> stderr (2)  error messages
                +---------------------+

Keeping normal output and errors on separate streams is deliberate: it lets you save results to a file while still seeing errors, or vice versa.

2. Redirecting output: >, >>, 2>, &>

A redirection changes where a stream goes. Send stdout to a file:

echo "line one" > out.txt     # > OVERWRITES the file (creates if missing)
echo "line two" >> out.txt    # >> APPENDS to the file
cat out.txt
# line one
# line two

> clobbers (replaces) the file every time — be careful. >> adds to the end.

Errors travel on stderr, so plain > does not capture them. Redirect stderr with 2>:

ls /does/not/exist 2> errors.txt    # error goes to the file, not the screen

Common combinations:

command > out.txt 2> err.txt    # stdout and stderr to SEPARATE files
command > all.txt 2>&1          # stderr follows stdout into the same file
command &> all.txt              # shorthand for the same thing (Bash)
command > /dev/null 2>&1        # discard everything (/dev/null is a black hole)

The order in 2>&1 matters: it means "make descriptor 2 point wherever descriptor 1 currently points," so it must come after you redirect stdout.

3. Pipes: |

A pipe connects the stdout of one command to the stdin of the next, so you can build a processing chain:

ls -l /etc | grep conf | wc -l

This lists /etc, keeps only lines containing conf, then counts them. Data flows left to right:

  ls -l /etc  --stdout-->  grep conf  --stdout-->  wc -l  --> screen

Pipes only connect stdout, not stderr. Each small tool does one job well and you compose them — the Unix philosophy.

4. Exit codes and $?

When a command finishes it returns an exit code: a number from 0 to 255. By convention 0 means success and anything non-zero means failure. This is how a script knows whether the previous step worked.

The special variable $? holds the exit code of the last command:

ls /etc > /dev/null
echo "$?"        # 0  (success)

ls /nope 2> /dev/null
echo "$?"        # 2  (failure)

You will use exit codes in the next lesson to make decisions. Your own scripts should set their exit code too, with exit:

#!/usr/bin/env bash
if [[ -f /etc/hostname ]]; then
  echo "found it"
  exit 0          # success
else
  echo "missing!" >&2   # send our error to stderr
  exit 1          # failure
fi

Note >&2 — that redirects our echo to stderr, the correct stream for error messages, so callers can separate them from real output.

5. Reading input with read

read pulls a line from stdin into a variable. Combined with -p it prompts the user:

#!/usr/bin/env bash
read -rp "What is your name? " name
echo "Hello, $name"

You can read several values at once; read splits on whitespace:

read -rp "Enter first and last name: " first last
echo "First: $first   Last: $last"

Because read reads from stdin, it also works with pipes and files:

while read -r line; do
  echo "got: $line"
done < out.txt

That < out.txt is input redirection — it feeds the file into the loop's stdin instead of the keyboard.

6. Here-documents

A here-document (here-doc) feeds a block of multi-line text into a command's stdin without a separate file. You pick a marker word (commonly EOF):

cat <<EOF > config.txt
host = jumpbox
user = ubuntu
date = $(date +%F)
EOF

Everything between <<EOF and the closing EOF becomes input to cat, which here writes it to config.txt. Variables and $( ) are expanded inside, as shown. If you quote the marker — <<'EOF' — expansion is turned off and the text is taken literally, which is useful when writing scripts that contain $ signs.

grep "user" <<< "user=ubuntu"

7. Putting it together

#!/usr/bin/env bash
# Save a directory listing; log any error separately.
target="/etc"
if ls -l "$target" > listing.txt 2> errors.log; then
  echo "Listing saved ($(wc -l < listing.txt) lines)"
else
  echo "ls failed, see errors.log" >&2
  exit 1
fi

This captures normal output to one file, errors to another, checks the exit code, and reports cleanly on the right streams — exactly the discipline real scripts need.

Dig deeper

Search terms

Check yourself

  1. Name the three standard streams and their file descriptor numbers.
  2. What is the difference between > file and >> file?
  3. What does command > out.txt 2>&1 do, and why must 2>&1 come last?
  4. By convention, what exit code means success, and how do you read the last command's exit code?
  5. Write a here-doc that writes two lines to a file called notes.txt.

Lesson: Conditionals & Tests

What you'll learn

By the end you can make scripts that react to their environment — checking whether a file exists, whether a service is up, or whether a value is what you expect.

The lesson

Real automation has to make decisions: if the disk is full, warn; if the service is down, restart it. In Bash, decisions are built on exit codes (from the last lesson) and the if statement.

Run everything on the Jumpbox (10.100.100.254, user ubuntu).

1. if is driven by exit codes

An if statement runs a command and branches on its exit code: 0 (success) is "true", non-zero is "false". This surprises newcomers — there is no boolean type, just exit codes.

if grep -q "ubuntu" /etc/passwd; then
  echo "user ubuntu exists"
else
  echo "no such user"
fi

grep -q prints nothing but exits 0 if it found a match. The structure is always:

if  <command>; then
  <runs when command succeeds / exit 0>
elif <other command>; then
  <runs when that one succeeds>
else
  <runs when none succeeded>
fi

Note fi (if backwards) closes the block, and the then needs a ; or a newline before it.

2. The test command [[ ]]

Most of the time you are not testing a real command but a condition — is this number bigger, does this file exist? Bash gives you [[ ... ]] for that. It is itself a command that exits 0 (true) or 1 (false):

count=5
if [[ $count -gt 3 ]]; then
  echo "more than three"
fi

You may also see single-bracket [ ... ] (the older test command). Prefer [[ ]] in Bash: it is safer with empty variables, supports &&/|| inside, and does pattern matching. Always put spaces inside the brackets — [[$count is an error.

3. String tests

name="ubuntu"

[[ $name == "ubuntu" ]]      # equal
[[ $name != "root" ]]        # not equal
[[ -z $name ]]               # true if string is EMPTY (zero length)
[[ -n $name ]]               # true if string is NOT empty
[[ $name == ub* ]]           # pattern match: starts with "ub"

Inside [[ ]], the right side of == is treated as a glob pattern unless quoted. So [[ $f == *.log ]] is true for any name ending in .log. Quote it ("*.log") to match literally.

A very common safety check:

read -rp "Enter a username: " u
if [[ -z $u ]]; then
  echo "You must enter a name" >&2
  exit 1
fi

4. Number tests

Numeric comparisons use letter operators, not symbols:

  -eq   equal              -ne   not equal
  -gt   greater than       -lt   less than
  -ge   greater or equal   -le   less or equal
disk_used=82
if [[ $disk_used -ge 90 ]]; then
  echo "CRITICAL: disk over 90%"
elif [[ $disk_used -ge 75 ]]; then
  echo "WARNING: disk getting full"
else
  echo "Disk OK"
fi

Do not use > or < for numbers inside [[ ]] — there they mean string ordering, not numeric. Use -gt, -lt, etc. (For arithmetic you can also use (( )): if (( disk_used >= 90 )); then.)

5. File tests

These are the bread and butter of sysadmin scripts:

  -e PATH   exists (file or directory)
  -f PATH   exists AND is a regular file
  -d PATH   exists AND is a directory
  -r PATH   is readable        -w writable     -x executable
  -s PATH   exists AND is non-empty
config="/etc/hosts"
if [[ -f $config && -r $config ]]; then
  echo "$config is a readable file"
else
  echo "Cannot read $config" >&2
  exit 1
fi

Notice && inside [[ ]] means "both conditions true". You can also use || for "either", and ! for "not": if [[ ! -d /backups ]]; then mkdir /backups; fi.

6. Chaining with && and || (outside brackets)

Between whole commands, && and || are shortcuts based on exit codes:

mkdir -p /tmp/work && echo "ready"        # echo runs ONLY if mkdir succeeded
ping -c1 example.com || echo "host down"  # echo runs ONLY if ping failed

This is a concise alternative to a full if for one-liners. A handy pattern for "do this or bail out":

cd /var/log || exit 1

If cd fails, the script stops instead of running the rest in the wrong directory — a real bug-preventer.

7. case for many options

When you are comparing one value against many possibilities, a chain of elif gets ugly. case is cleaner:

read -rp "Action (start/stop/status): " action
case "$action" in
  start)
    echo "starting..." ;;
  stop)
    echo "stopping..." ;;
  status)
    echo "checking..." ;;
  *)
    echo "Unknown action: $action" >&2
    exit 1 ;;
esac

Each branch is a pattern, then ), then commands, ending in ;;. The *) at the end is the catch-all (default), like else. Patterns are globs, so you can match *.log) or y|Y|yes) for several values at once. case closes with esac (case backwards).

8. Putting it together

#!/usr/bin/env bash
# Warn if a log directory is missing or its disk usage is high.
logdir="/var/log"
if [[ ! -d $logdir ]]; then
  echo "No $logdir directory" >&2
  exit 1
fi

used=$(df --output=pcent "$logdir" | tail -1 | tr -dc '0-9')
if [[ $used -ge 90 ]]; then
  echo "CRITICAL: $logdir at ${used}%"
  exit 2
else
  echo "$logdir usage ${used}% — OK"
fi

This checks a file condition, computes a number from a command, and branches on it — the shape of the health-check you will build in the assignment.

Dig deeper

Search terms

Check yourself

  1. In an if, what exit code counts as "true" and which counts as "false"?
  2. Why is [[ ]] preferred over single-bracket [ ] in Bash?
  3. Which operator tests whether a string is empty, and which tests numeric "greater than"?
  4. What is the difference between A && B and A || B?
  5. In a case statement, what does the *) branch do, and what closes the whole block?

Lesson: Loops, Arguments & Functions

What you'll learn

By the end you can write scripts that process many items, accept input from the command line, and stay organized as they grow.

The lesson

So far each script ran top to bottom once. Real automation repeats — over every file, every server, every line of a log — and takes input so the same script handles different jobs. That is loops, arguments, and functions.

Run everything on the Jumpbox (10.100.100.254, user ubuntu).

1. The for loop

A for loop walks through a list, binding each item to a variable in turn:

for name in alice bob carol; do
  echo "Hello, $name"
done

The flow:

  list:  alice   bob   carol
           |       |      |
           v       v      v
         name=   name=  name=    <- body runs once per item

The list can come from a glob (file pattern), command substitution, or a number range:

for f in /etc/*.conf; do          # every .conf file
  echo "Found config: $f"
done

for n in $(seq 1 5); do           # 1 2 3 4 5
  echo "Attempt $n"
done

for i in {1..3}; do               # brace expansion: 1 2 3
  echo "Round $i"
done

Always quote "$f" when you use the loop variable — filenames can contain spaces. To process the lines of a file safely, prefer a while read loop (next).

2. The while loop

A while loop repeats as long as a command keeps succeeding (exit 0):

count=1
while [[ $count -le 3 ]]; do
  echo "count is $count"
  count=$((count + 1))     # arithmetic with $(( ))
done

$(( ... )) does integer math. The most reliable way to read a file or command output line by line is:

while read -r line; do
  echo "log line: $line"
done < /var/log/syslog

This handles spaces and odd characters far better than a for loop over file contents. You can also pipe into it: df -h | while read -r line; do ...; done.

To stop early use break; to skip to the next iteration use continue:

for f in *.txt; do
  [[ -s $f ]] || continue       # skip empty files
  echo "processing $f"
done

3. Script arguments

When you run ./deploy.sh web01 prod, the words after the script name become positional parameters:

  ./deploy.sh   web01   prod
       $0        $1      $2
#!/usr/bin/env bash
echo "Script: $0"
echo "First arg: $1"
echo "Number of args: $#"

if [[ $# -lt 1 ]]; then
  echo "Usage: $0 <hostname> [environment]" >&2
  exit 1
fi

for arg in "$@"; do
  echo "Got argument: $arg"
done

Use "$@" (with quotes), not $* or unquoted $@. Quoted "$@" preserves arguments that contain spaces; the others mangle them. Checking $# up front and printing a usage line is the mark of a polished script.

4. Functions

A function is a named block of commands you can call repeatedly. It keeps scripts readable and avoids copy-paste:

#!/usr/bin/env bash

log() {
  echo "[$(date +%T)] $*"
}

check_dir() {
  local dir="$1"          # 'local' keeps the variable inside the function
  if [[ -d $dir ]]; then
    log "OK: $dir exists"
  else
    log "MISSING: $dir"
  fi
}

check_dir /etc
check_dir /nope

Key points:

5. Return values from functions

Functions return an exit code (0–255), not a string, via return. That code feeds straight into if:

is_running() {
  systemctl is-active --quiet "$1"   # exits 0 if the service is active
}

if is_running ssh; then
  echo "ssh is up"
else
  echo "ssh is down"
fi

To get data (a string or number) out of a function, have it echo the value and capture it with command substitution:

disk_pct() {
  df --output=pcent "$1" | tail -1 | tr -dc '0-9'
}

used=$(disk_pct /)
echo "Root filesystem is ${used}% full"

So: return for success/failure, echo + $( ) for actual values. Mixing these up is a classic beginner bug.

6. Basic arrays

An array holds an ordered list of values in one variable:

services=(ssh cron systemd-journald)

echo "${services[0]}"        # ssh           (first element, index 0)
echo "${services[@]}"        # all elements
echo "${#services[@]}"       # 3             (count of elements)

services+=(nginx)            # append an element

Loop over an array with quoted "${services[@]}":

for svc in "${services[@]}"; do
  if systemctl is-active --quiet "$svc"; then
    echo "$svc: up"
  else
    echo "$svc: DOWN"
  fi
done

Arrays are perfect for "a list of things to check" — services, hosts, directories — which is exactly what your health-check assignment needs.

7. Putting it together

#!/usr/bin/env bash
# Check several services passed as arguments; exit non-zero if any is down.
if [[ $# -eq 0 ]]; then
  echo "Usage: $0 <service> [service...]" >&2
  exit 1
fi

failed=0
for svc in "$@"; do
  if systemctl is-active --quiet "$svc"; then
    echo "$svc: up"
  else
    echo "$svc: DOWN" >&2
    failed=$((failed + 1))
  fi
done

echo "$failed service(s) down"
[[ $failed -eq 0 ]]      # last command's exit code becomes the script's

This takes a variable list of arguments, loops over them, counts failures, and sets a meaningful exit code — clean, reusable, and ready for Git.

Dig deeper

Search terms

Check yourself

  1. When looping over the lines of a file, why is while read -r line preferred over a for loop?
  2. What is the difference between $#, $1, and "$@"?
  3. Why should you write "$@" with quotes instead of $@ or $*?
  4. How does a function "return" a number versus an actual string value?
  5. Given services=(ssh cron nginx), how do you print the number of elements?

Lesson: Scripts You Can Trust

What you'll learn

By the end you can write scripts that fail safely, are checked by a tool, clean up after themselves, and that you would trust to run unattended.

The lesson

A script that works once on your machine is easy. A script you trust to run on a server at 3 a.m. — and not delete the wrong thing — is the real skill. This lesson is the difference between a beginner's script and a professional one.

Run everything on the Jumpbox (10.100.100.254, user ubuntu). Because you will commit these scripts to Git soon, robustness and readability are part of the deliverable.

1. The safety header: set -euo pipefail

By default, Bash keeps going after errors and treats undefined variables as empty — a recipe for disaster. Put this near the top of every serious script:

#!/usr/bin/env bash
set -euo pipefail

What each part does:

  set -e            exit immediately if any command fails (non-zero)
  set -u            error on use of an UNSET variable (catches typos)
  set -o pipefail   a pipeline fails if ANY command in it fails,
                    not just the last one

Why it matters — the classic horror story:

# WITHOUT set -u, if $dir is unset/typo'd this becomes: rm -rf /
rm -rf "$dir/"

# WITH set -u, the script stops on the unset variable instead.

set -e is helpful but has sharp edges: a command that is expected to fail (like grep finding nothing) would stop the script. Handle those explicitly:

if grep -q pattern file; then ...; fi    # fine: the 'if' "uses" the exit code
count=$(grep -c pattern file || true)    # 'or true' stops -e from firing

Add the header, then test that your script still does the right thing.

2. Quoting pitfalls (the #1 source of bugs)

You learned to quote variables in Lesson 1. Here is why it bites in practice:

file="my report.txt"
rm $file          # WRONG: runs  rm my report.txt  -> deletes TWO files!
rm "$file"        # RIGHT: deletes the one file "my report.txt"

Rules to internalize:

When in doubt, quote. There is rarely a downside.

3. ShellCheck — let a tool find the bugs

ShellCheck is a free static analyzer that reads your script and flags exactly these mistakes. Install and run it on the Jumpbox:

sudo apt-get install -y shellcheck
shellcheck myscript.sh

It produces messages like:

In myscript.sh line 7:
rm $file
   ^--^ SC2086: Double quote to prevent globbing and word splitting.

Each SCxxxx code links to a wiki page explaining the fix. Make "ShellCheck-clean" your standard before you commit a script to Git. You can even add a directive to silence a specific warning when you are sure: # shellcheck disable=SC2034.

4. Cleaning up with trap

A trap runs a command when your script exits or receives a signal (like Ctrl+C). Use it to remove temp files no matter how the script ends:

#!/usr/bin/env bash
set -euo pipefail

tmp="$(mktemp)"                 # safe unique temp file
trap 'rm -f "$tmp"' EXIT        # ALWAYS runs on exit, success or failure

echo "working..." > "$tmp"
# ... use $tmp ...
# no manual cleanup needed; the trap handles it

EXIT fires on normal end, on error (thanks to set -e), and on interruption. Common signals to trap: EXIT (always), INT (Ctrl+C), TERM. Use mktemp to make temp files — never hardcode /tmp/myfile, which is predictable and can be hijacked.

5. Idempotency

Idempotent means running the script twice has the same result as running it once — no errors, no duplicates, no harm. This is essential for automation that may re-run.

mkdir /backups          # FAILS the second time (already exists)
mkdir -p /backups       # idempotent: fine whether or not it exists

# Instead of blindly appending (which duplicates on re-run):
grep -qxF "$line" file || echo "$line" >> file   # add only if missing

Ask of every step: "what happens if this runs again?" Idempotent scripts are safe to retry, which is exactly what schedulers and config tools expect.

6. When Bash is the WRONG tool

Bash is excellent for gluing commands together, file shuffling, and short automation. It becomes painful — and you should reach for Python, Go, or a dedicated tool — when you have:

  - Complex data structures (nested JSON, real math, dates arithmetic)
  - Anything over ~100-200 lines or many functions
  - Heavy string parsing  -> use awk, or a real language
  - JSON/YAML             -> use jq / yq, not regex on text
  - Needs unit tests, libraries, or to run on Windows too

A good instinct: if you are fighting Bash to do something a real programming language makes easy, switch. Parsing JSON with grep and sed is a well-known trap — pipe it through jq instead. Knowing the boundary is itself a senior skill.

7. A robust template to start from

#!/usr/bin/env bash
#
# describe.sh — what this script does, in one line.
# Usage: ./describe.sh <arg>
#
set -euo pipefail

# --- config ---
readonly LOGDIR="/var/log"

# --- cleanup ---
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT

# --- functions ---
log() { echo "[$(date +%T)] $*"; }

die() { echo "ERROR: $*" >&2; exit 1; }

# --- args ---
[[ $# -ge 1 ]] || die "Usage: $0 <arg>"
arg="$1"

# --- main ---
log "Starting with arg=$arg"
[[ -d "$LOGDIR" ]] || die "$LOGDIR not found"
log "Done"

This template has the safety header, a trap, helper functions (log, die), argument validation, and readonly constants. Run shellcheck describe.sh and fix anything it reports. Habits like these are exactly what your mentor will look for when you commit these scripts to Git.

Dig deeper

Search terms

Check yourself

  1. What does each of -e, -u, and -o pipefail do in set -euo pipefail?
  2. Show a one-line example where forgetting to quote a variable causes a real bug.
  3. What is ShellCheck, and at what point in your workflow should you run it?
  4. What does a trap '...' EXIT guarantee, and why use mktemp?
  5. Give two situations where you should choose Python or another tool over Bash.

Assignment 1: A system health-check script

Goal: Write a Bash script that inspects the machine it runs on — disk usage, free memory, and whether a chosen service is running — and prints a clear, readable report with a meaningful exit code.

Where: On the Jumpbox (10.100.100.254, Ubuntu, user ubuntu). Create your script in your home directory, e.g. ~/healthcheck.sh.

Tasks

  1. Create ~/healthcheck.sh starting with the shebang #!/usr/bin/env bash and the safety header set -euo pipefail. Add a top comment describing what the script does and its usage.
  2. Add a log() helper function that prints messages prefixed with a timestamp (use date +%T).
  3. Disk check: Get the percentage used of the root filesystem / (hint: df --output=pcent / | tail -1 | tr -dc '0-9'). Store it in a variable. Print Disk /: NN%. If usage is >= 90, label it CRITICAL; if >= 75, label it WARNING; otherwise OK. Use if/elif/else with numeric tests.
  4. Memory check: Report available memory. Use free -m and extract the "available" column, or print the human-readable line with free -h | grep Mem. Print Memory available: NN MB (or the line). Optionally flag WARNING if available memory is below a threshold you choose.
  5. Service check: Accept a service name as the first argument $1 (default to ssh if none is given). Use systemctl is-active --quiet "$svc" inside an if to report Service <name>: up or Service <name>: DOWN.
  6. Exit code: Track whether anything was CRITICAL or the service was down. Exit 0 if all healthy, non-zero (e.g. 1) if any problem was found, so another script could rely on it.
  7. Make the script executable (chmod +x ~/healthcheck.sh) and run it both with no argument and with a service name, e.g. ./healthcheck.sh cron.
  8. Run shellcheck ~/healthcheck.sh and fix every warning until it is clean.

Deliverable

The file ~/healthcheck.sh on the Jumpbox: executable, ShellCheck-clean, with a header comment, a log() function, the three checks, and a meaningful exit code. Paste a sample run (with no argument and with one argument) into your notes to show your mentor.

Acceptance criteria — you're done when:

Hints

Blocked for more than ~30 minutes after re-reading the lessons? Bring what you've tried to your mentor.

Assignment 2: A safe backup/parameterised script

Goal: Write a robust, parameterised Bash script that backs up a directory into a timestamped compressed archive, validates its inputs, is safe to re-run, and cleans up after itself.

Where: On the Jumpbox (10.100.100.254, Ubuntu, user ubuntu). Create ~/backup.sh and back up something harmless you own, such as ~/scripts or a test folder you create.

Tasks

  1. Create ~/backup.sh with the shebang #!/usr/bin/env bash, the safety header set -euo pipefail, and a header comment showing usage: Usage: ./backup.sh <source-dir> [dest-dir].
  2. Arguments & validation: Read the source directory from $1 and an optional destination from $2 (default the destination to ~/backups). If no source is given, print the usage line to stderr and exit 1. If the source is not an existing directory (-d), print an error to stderr and exit non-zero.
  3. Add a die() helper that prints ERROR: <message> to stderr and exits 1, and use it for your validation messages.
  4. Idempotent destination: Create the destination directory with mkdir -p "$dest" so re-running never errors.
  5. Timestamped archive: Build an archive name that includes the source's basename and a timestamp, e.g. scripts-2026-06-01_142530.tar.gz (hint: date +%F_%H%M%S). This guarantees each run makes a new file rather than overwriting an old backup.
  6. Create the backup: Use tar -czf "$dest/$archive" -C "$parent" "$base" to compress the source. Capture/print the resulting file size (e.g. du -h "$dest/$archive").
  7. trap cleanup: If you build the archive via a temporary file or staging area, register trap 'rm -f "$tmp"' EXIT so partial files are cleaned up if the script fails partway.
  8. Report: Print a clear success line: Backup complete: <path> (<size>). Exit 0 on success.
  9. Make it executable, run it against a real folder, then run it a second time to prove it is idempotent (no errors, a new timestamped file appears).
  10. Run shellcheck ~/backup.sh and resolve all warnings.

Deliverable

The file ~/backup.sh on the Jumpbox: executable, ShellCheck-clean, parameterised, input-validated, idempotent, with a trap and a die() helper. Show your mentor the directory listing of ~/backups after running the script twice (two timestamped archives).

Acceptance criteria — you're done when:

Hints

Blocked for more than ~30 minutes after re-reading the lessons? Bring what you've tried to your mentor.