Phase 1 · FOUNDATIONS
Shell scripting 2 — functions, exit codes, a real backup script
By the end of today
- Package repeated work into shell functions and pass them arguments
- Read exit codes with $? and fail fast using set -euo pipefail
- Build a real tar backup script with functions and error handling
Functions, exit codes, and failing fast
Yesterday’s scripts ran top to bottom. Real scripts do two more things: they package repeated work into functions you call by name, and they check whether each step actually worked before charging on to the next. Get those two habits right and a script stops being a fragile list of commands and becomes something you would trust to run unattended at 2 a.m.
A function is a named block of commands you define once and call by name. Inside it, $1 is the first argument you passed, $2 the second, and $@ is all of them at once — the same positional variables a whole script sees, scoped to that one call. So a log() function can wrap every message in a timestamp, and a backup() function can take the directory to save as $1: you write the logic once and reuse it, instead of copy-pasting the same five lines everywhere.
Every command a function or script runs leaves behind an exit code: a single number the shell stores in the special variable $?. Zero means success; anything from 1 to 255 means failure. grep returns 1 when it finds nothing; tar returns non-zero when a file it was told to archive is missing. Reading $? straight after a command — or letting if some_command; then test it for you — is how a script knows whether to continue, retry, or bail out. Your own functions set their code with return N; a whole script sets its with exit N, which is exactly what a CI pipeline or a systemd service reads to decide “did this job pass?”.
Real world: A function is a recipe card in a busy kitchen. Instead of shouting all twelve steps for “make the sauce” every time an order lands, the head chef writes the recipe once and just calls “sauce, table four” — and the cook reports back “done” or “we’re out of cream” (the exit code). One card, reused all night; a clear yes-or-no answer every single time.
Failing fast with set -euo pipefail
By default, bash is alarmingly forgiving: if a command fails midway through a script, it shrugs and runs the next line anyway — so a backup script whose tar step failed will still cheerfully print “Backup complete.” The one line professional scripts open with fixes exactly that — set -euo pipefail:
-e— exit the whole script the instant any command returns non-zero, instead of blundering on.-u— treat an unset variable as an error, so a typo’d$DESTINATONstops the script instead of silently expanding to nothing.-o pipefail— make a pipeline fail if any stage fails, not only the last, somysqldump | gzipreports the dump breaking, not just gzip succeeding.
A named example makes it concrete. GitHub Actions runs every bash step with set -eo pipefail already applied — its default shell is literally bash --noprofile --norc -eo pipefail. That is why a failing command in a workflow step aborts the job instead of letting a broken build march on to a green check. Writing set -euo pipefail at the top of your own scripts makes your laptop behave the way the pipeline already does.
Today you combine both: functions for structure, exit codes and set -euo pipefail for safety, in one real tar-based backup script.
Hands-On Lab
Budget about 25 minutes. Open your WSL2 Ubuntu 24.04 terminal (if you have not set up WSL2 yet, do Day 0 first — macOS and Linux users can follow along in their built-in terminal). You will feel out functions and exit codes first, then assemble them into a backup script you actually run. Type every command yourself and read every line of output.
# 1. A function is a named block of commands. $1 is its first argument, $# the count, $@ all of them.
greet() { echo "Hello, $1 — you passed $# argument(s): $@"; }
greet pushkar
# Output:
# Hello, pushkar — you passed 1 argument(s): pushkar
# 2. Every command leaves an exit code in $?. 0 = success, non-zero = failure.
ls /home >/dev/null; echo "ls exit code: $?"
ls /nope 2>/dev/null; echo "ls exit code: $?"
# Output (ls returns 2 when the path does not exist):
# ls exit code: 0
# ls exit code: 2
# 3. Exit codes carry meaning: grep returns 1 when it finds NO match — that is not a crash.
echo "INFO all good" | grep ERROR; echo "grep exit code: $?"
# Output (grep printed nothing, then reported 1 = "no lines matched"):
# grep exit code: 1
# 4. Your own functions set their exit code with `return`. Test it straight after with $?.
is_root() { [ "$(id -u)" -eq 0 ]; }
is_root; echo "am I root? exit code: $?"
# Output (you are the normal user pushkar, uid 1000, so the test is false → 1):
# am I root? exit code: 1
# 5. set -u makes an unset variable a hard error instead of an empty string. Try it in a subshell.
( set -u; echo "dest is: $DESTINATION" )
# Output:
# bash: DESTINATION: unbound variable
# 6. set -e stops the script the instant a command fails. Compare a subshell with and without it.
( echo one; false; echo two )
( set -e; echo one; false; echo two )
# Output (first subshell runs "two" anyway; the second aborts at false, never printing "two"):
# one
# two
# one
# 7. A pipeline's exit code is normally just the LAST stage's. pipefail makes any failure count.
false | cat; echo "without pipefail: $?"
( set -o pipefail; false | cat ); echo "with pipefail: $?"
# Output (without pipefail cat succeeded so the pipe reports 0; pipefail surfaces false's 1):
# without pipefail: 0
# with pipefail: 1
# 8. Make a small directory to back up — a stand-in for a real app's config and data.
mkdir -p ~/site && echo "server config" > ~/site/nginx.conf && echo "<h1>index</h1>" > ~/site/index.html
ls ~/site
# Output:
# index.html nginx.conf
# 9. Write a real backup script: functions, arguments, set -euo pipefail, and an error guard.
cat > ~/backup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
SRC="${1:-$HOME/site}" # what to back up (1st arg, default ~/site)
DEST="${2:-$HOME/backups}" # where archives go (2nd arg, default ~/backups)
STAMP="$(date +%Y%m%d-%H%M%S)"
ARCHIVE="$DEST/backup-$STAMP.tar.gz"
log() { echo "[$(date +%H:%M:%S)] $*"; }
die() { log "ERROR: $*"; exit 1; }
[ -d "$SRC" ] || die "source '$SRC' does not exist"
mkdir -p "$DEST"
log "Backing up $SRC"
tar -czf "$ARCHIVE" -C "$(dirname "$SRC")" "$(basename "$SRC")"
log "Wrote $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
EOF
chmod +x ~/backup.sh
echo "backup.sh written and made executable"
# Output:
# backup.sh written and made executable
# 10. Run it with no arguments — it uses the defaults (~/site → ~/backups) and reports each step.
~/backup.sh
# Output (your HH:MM:SS and the size will differ):
# [14:07:12] Backing up /home/pushkar/site
# [14:07:12] Wrote /home/pushkar/backups/backup-20260711-140712.tar.gz (4.0K)
# 11. Point it at a directory that does not exist — the die() guard fails fast with a clear message.
~/backup.sh /home/pushkar/does-not-exist; echo "script exit code: $?"
# Output:
# [14:07:20] ERROR: source '/home/pushkar/does-not-exist' does not exist
# script exit code: 1
# 12. Prove the backup is real: list what the archive contains.
tar -tzf ~/backups/backup-*.tar.gz
# Output (paths are relative — the -C flag kept the leading directory off):
# site/
# site/index.html
# site/nginx.conf
Read the last outputs back: a function packaged the logic, $? and set -euo pipefail decided whether each step lived or died, and the same guard that failed fast on a missing directory let a real archive land safely on disk — structure and safety in one script you would trust to run on a schedule.
Common Errors & Fixes
These three catch almost everyone the first time they move from one-off commands to a script they run unattended. Read the error text slowly — learning to parse it is the actual skill.
Common error: Running the script with
cd ~ && sh backup.shinstead of executing it directly:backup.sh: 2: set: Illegal option -o pipefailWhy: On Ubuntu,
/bin/shis dash, a minimal POSIX shell that lacks bash extensions like-o pipefail. Invokingsh scriptignores the#!/usr/bin/env bashshebang and forces dash, which chokes on line 2.Fix: Run it as
./backup.sh(afterchmod +x) orbash ~/backup.sh— either way the bash shebang selects the right interpreter, andpipefailis understood.How you’d spot it in prod: A script that works locally but dies in CI or cron with “Illegal option” or “Syntax error: … unexpected” almost always means it is being launched with
sh, notbash. Check the shebang and exactly how the job invokes the file.
Common error: Referencing a positional argument under
set -uwith no default, then running the script with no argument (hereSRC="$1"on line 4):./backup.sh: line 4: $1: unbound variableWhy:
set -utreats any unset variable — including positional parameters like$1— as a fatal error. Called with no argument,$1is unset, so the script aborts before doing anything.Fix: Give positional args a default with
${1:-...}— exactly what the lab script does with${1:-$HOME/site}— or check[ $# -ge 1 ]up front and print a usage line.How you’d spot it in prod: “unbound variable” the first time a job runs without an argument or env var you always have set on your laptop — a value present interactively but missing in the clean cron/CI environment.
Common error: A
set -escript that usesgrepto count matches stops silently on a quiet day:$ ./scan.sh # prints the first line, then just stops [14:20:03] scanning log… $ echo $? 1Why: Under
set -e, any command returning non-zero aborts the script — andgrepreturns 1 when it simply finds no matches, which is not a real error. A line likecount=$(grep -c ERROR log)kills the run whenever the log happens to be clean.Fix: Tell the shell this particular non-zero is fine:
grep -c ERROR log || true, or use anif(whichset -edeliberately ignores):if grep -q ERROR log; then …; fi.How you’d spot it in prod: A
set -escript that “randomly” bails early — it runs fine when there is something to match and mysteriously stops when there is not. The culprit is nearly always agrep,diff, ortestwhose non-zero exit is expected.
Shell Scripting Interview Questions
Cover the answers below and say your own version out loud first — explain what $?, return and set -euo pipefail each do before you reveal each answer. Recalling before revealing is what makes these stick when an interviewer asks them cold. The four questions and answers render right after this note.
Go Deeper
Optional extras if you have ~25 more minutes today:
- 5 min — Run
help setin bash and read the full list of shell options behind-e,-uand-o pipefail; there are more safety switches worth knowing. - 10 min — Install ShellCheck and lint your script:
sudo apt install shellcheck && shellcheck ~/backup.sh. It flags unquoted variables and swallowed exit codes before they bite you in production. - 10 min — Read the “Introduction to Linux” section of the Linux for DevOps guide for how scripting fits the wider automation picture you are building toward.
Explain what set -euo pipefail does and why you put it at the top of a script. Both
It is the safety line I put at the top of every serious script. -e makes the script exit the moment any command fails, instead of blindly running the next one. -u turns using an unset variable into an error, so a typo'd variable name stops the script rather than silently expanding to nothing. -o pipefail makes a pipeline fail if any stage fails, not just the last — so mysqldump piped into gzip reports the dump breaking, not only gzip succeeding. Together they turn bash's forgiving defaults into fail-fast behaviour, which is exactly what you want for a backup or deploy script running unattended.
What is an exit code, and how do you check the last command's? Both
Every command returns an exit code when it finishes — a number the shell stores in the special variable $?. Zero means success; anything from 1 to 255 means failure, and some tools give specific codes, like grep returning 1 when it finds no match. I read it with echo $? right after the command, but in scripts I usually let control flow test it for me — 'if mycommand; then' runs the branch only when the command succeeded. My own functions set their code with return N and a whole script with exit N. That final code is what a CI job or systemd reads to decide whether the step passed.
What is the difference between return and exit in a shell script? Service
return ends a function and hands an exit code back to whatever called it — the script keeps running. exit ends the whole script, and if the script is your current shell, it closes that shell. So inside a helper function I use return 1 to signal failure to the caller, which can then decide what to do next. I save exit for the top level, usually in a die() helper that logs a clear message and exits non-zero so the caller — a pipeline or a cron job — knows the run failed. On client work that distinction matters: a reusable function should not kill the whole automation just because one step it does can fail.
How do you make a shell script fail fast with a clear error message? Product
Two habits. First, set -euo pipefail at the top so any failing command, unset variable, or broken pipe stops the script immediately instead of continuing in a bad state. Second, a small die() helper — it logs a specific message and calls exit 1 — that I call after guard checks, like testing a directory exists before I try to back it up. So instead of a cryptic tar error halfway through, the user sees 'ERROR: source /data does not exist' and the script stops with a non-zero code. Failing fast with a readable message is what makes a script safe to run unattended and easy to debug from a log.
Mark Day 16 complete
Your scripts are written now — tomorrow Git gives every version a history, a branch, and a way back.
Stuck on today’s lab? Ask in Mission 90 Q&A