Master shell scripting through hands-on exercises in an interactive Linux environment — from your first script to building real automation tools
Welcome to the Shell Scripting Tutorial! On the left is a code
editor; on the right is a real Linux terminal. Files you save in the
editor are synced to /tutorial/ on the VM.
Every time you repeat a task in the terminal — renaming files, checking server logs, running builds — you are a candidate for automation. A shell script captures those commands in a file so you can re-run, share, and schedule them without retyping anything.
Shell scripts are the foundation of CI/CD pipelines, Docker entrypoints, deployment scripts, and system administration. The skills you learn here transfer directly to real production workflows.
Open morning.sh in the editor. It already has:
#!/bin/bash
set -e
Line 1 — the shebang (#!): When you run a file, Linux reads the
first two bytes to decide how to execute it. #! followed by a path
tells the OS which interpreter to use. Without it, the OS guesses —
and often guesses wrong. Always write #!/bin/bash on line 1.
Line 2 — the safety net (set -e): By default, Bash happily
continues running after a failed command. set -e stops the script
immediately when something goes wrong, preventing a cascade of
confusing failures. Always include it.
Add the following three lines below the existing ones:
echo "Good morning!"
echo "Today is $(date +%A)."
echo "You are logged in as: $(whoami)"
Then save (Ctrl+S / Cmd+S) and run in the terminal:
chmod +x morning.sh
./morning.sh
Breaking it down:
chmod +x grants execute permission. Linux requires this explicit
step before running a file as a program — a deliberate security
feature so files don’t accidentally become executable../morning.sh — the ./ prefix means “look in the current
directory.” The shell only searches directories listed in $PATH
for commands; your local folder is not in $PATH by default.$(date +%A) is command substitution: the shell runs date +%A
first, captures its output, and injects the result into your string.
Any command can go inside $(...) — this is one of Bash’s most
useful features.#!/bin/bash set -e
The pipe operator | is one of the most powerful ideas in Unix.
It connects programs so that the output of one becomes the input of
the next, letting you build data-processing pipelines from small,
single-purpose tools.
Each Unix tool does one thing well:
grep finds matching linessort sorts lines alphabetically or numericallyuniq removes consecutive duplicate lineswc counts lines, words, or characterscut extracts specific columnsPipes let you compose them into powerful chains without writing a program. Data flows through memory from one process to the next — no intermediate files needed.
A file called server_log.txt is provided. Browse it first:
cat server_log.txt
Type these yourself — building pipelines step by step trains you to think in terms of data flowing through stages.
# 1. Count total lines
wc -l < server_log.txt
# 2. Show only ERROR lines
grep "ERROR" server_log.txt
# 3. Count how many errors
grep "ERROR" server_log.txt | wc -l
# 4. Extract just the timestamps (first field)
cut -d' ' -f1 server_log.txt | head -5
# 5. Which message types appear most often?
cut -d' ' -f2 server_log.txt | sort | uniq -c | sort -rn | head -3
Read pipeline 5 left to right: extract column 2 → sort alphabetically (so duplicates become adjacent) → count consecutive duplicates → sort by count descending → show top 3. Each step is trivial; the combination is powerful.
Pipes connect commands to commands. Redirection connects commands to files:
grep "ERROR" server_log.txt > errors_only.txt # create/overwrite
echo "extra line" >> errors_only.txt # append (safe)
wc -l < errors_only.txt # read from file
Note: > overwrites without warning. Use >> to append and
preserve existing content.
Now create errors_only.txt by running the grep "ERROR"
redirection above — the tests below will check it.
08:12:01 INFO server started on port 8080 08:12:03 INFO database connection established 08:14:22 WARN high memory usage detected (82%) 08:15:45 ERROR failed to process request /api/users 08:16:01 INFO request completed in 230ms 08:18:33 ERROR database timeout after 30s 08:19:02 WARN disk usage above threshold (91%) 08:20:15 INFO cache refreshed successfully 08:22:47 ERROR connection refused by upstream service 08:23:01 INFO retry succeeded for /api/users 08:25:00 INFO scheduled backup completed 08:27:12 WARN deprecated API endpoint called: /v1/legacy 08:30:00 INFO health check passed 08:31:44 ERROR out of memory on worker-3 08:32:01 INFO worker-3 restarted
Variables store values for reuse. In Bash, you assign with = and
read with $.
color="blue" # correct
color = "blue" # WRONG — shell sees three words: "color", "=", "blue"
There must be no spaces around =. The shell is a command
language: color = "blue" looks like running a command named color
with arguments = and blue. You will get a “command not found”
error that can be confusing at first.
When you write $variable, the shell replaces it with the value —
then word-splits the result on spaces and runs glob expansion.
This causes chaos when values contain spaces:
file="my report.txt"
wc -l $file # shell splits into: wc -l my report.txt (TWO args!)
wc -l "$file" # correct: one argument, treated as a unit
Rule: always double-quote your variables unless you have a specific reason not to.
| Quote style | Variables expand? | Example output |
|---|---|---|
"double" |
Yes | "Hello $name" → Hello Alice |
'single' |
No (literal) | 'Hello $name' → Hello $name |
Use double quotes almost always. Use single quotes when you want a
literal $ — for example, in awk programs or regex patterns.
buggy.sh has a deliberate quoting mistake. Run it first:
bash buggy.sh
Read the error carefully — it shows exactly what happens when a space-containing variable is unquoted.
buggy.sh in the editor.$filename on the wc -l line."$filename".Open inventory.sh and write a script that:
$(...) to capture the number of .sh files in the current
directory (hint: ls *.sh | wc -l).Project: mytools v1.0 — 3 scripts foundchmod +x inventory.sh
./inventory.sh
#!/bin/bash set -e # This script has a quoting bug — can you find it? filename="my report.txt" echo "creating a test file..." echo "important data" > "$filename" # BUG: the variable below is not quoted! line_count=$(wc -l $filename) echo "Line count: $line_count" rm "$filename"
#!/bin/bash set -e # Create variables for a project name and version, then count .sh files
Scripts need to react to different situations. Bash’s if statement
runs commands conditionally based on whether a test succeeds.
if [ condition ]; then
# runs when condition is true
elif [ other_condition ]; then
# runs when first is false but this is true
else
# runs when all conditions are false
fi
[ ] are mandatory[ is not special syntax — it is an actual command (try which [).
Like any command, its arguments must be separated by spaces:
[ -f "$file" ] # correct: "[" receives "-f" and "$file" as args
[-f "$file"] # WRONG: shell tries to run a command named "[-f"
| Test | Meaning |
|---|---|
-f path |
Path exists and is a regular file |
-d path |
Path exists and is a directory |
-z "$var" |
String is empty (zero length) |
-n "$var" |
String is not empty |
"$a" = "$b" |
Strings are equal |
$x -eq $y |
Integers are equal |
$x -lt $y |
Integer less than |
$x -gt $y |
Integer greater than |
! condition |
Logical NOT |
Important: use -eq, -lt, -gt for numbers; use = and !=
for strings. Mixing them gives wrong results silently.
Error messages should go to standard error (>&2), not standard
output. This lets callers separate normal output from errors:
echo "Error: file not found" >&2
exit 1
Edit health_check.sh and fill in the three _____ placeholders:
! -f "$file".error_count is greater than 3.error_count is greater than 0.chmod +x health_check.sh
./health_check.sh server_log.txt # should report CRITICAL (4 errors)
./health_check.sh nonexistent.txt # should print an error and exit 1
#!/bin/bash
set -e
file="${1:-server_log.txt}"
# Step 1: Check if the file exists
if [ _____ ]; then
echo "Error: $file not found" >&2
exit 1
fi
# Step 2: Count ERROR lines
error_count=$(grep -c "ERROR" "$file")
# Step 3: Decide severity
if [ _____ ]; then
echo "CRITICAL: $error_count errors found"
elif [ _____ ]; then
echo "WARNING: $error_count errors found"
else
echo "OK: no errors found"
fi
When you run ./script.sh one two three, the shell sets special
variables automatically:
| Variable | Contains |
|---|---|
$0 |
The script’s own name (great for usage messages) |
$1, $2, … |
Positional arguments |
$# |
Total number of arguments passed |
$@ |
All arguments as separate, properly-quoted words |
$? |
Exit code of the last command (0 = success) |
A script that silently does the wrong thing when called incorrectly is worse than one that fails clearly. Good scripts check their inputs:
if [ $# -eq 0 ]; then
echo "Usage: $0 <file1> [file2...]" >&2
exit 1
fi
Using $0 in the usage message means it always shows the correct
script name, even if the script is renamed.
"$@" expands to all arguments as separate, properly-quoted words:
for f in "$@"; do
echo "Processing: $f"
done
Always quote "$@" — unquoted $@ has the same space-splitting
problem as unquoted variables.
Write file_info.sh from scratch. It should:
for f in "$@".<name>: not found if it doesn’t exist.<name>: directory if it’s a directory.<name>: <N> lines otherwise (use wc -l < "$f").Test with:
chmod +x file_info.sh
./file_info.sh server_log.txt morning.sh /tmp nope.txt
You should see a line count, a directory label, and a “not found” message — all from one call.
#!/bin/bash set -e # Report info about each file given as an argument
Loops eliminate repetition. Instead of writing the same logic ten times for ten files, you write it once and let the loop do the rest.
for fruit in apple banana cherry; do
echo "I like $fruit"
done
The list can be literal words, a glob, or command output:
for f in *.sh; do # expands to all matching filenames
echo "Found: $f"
done
counter=1
while [ $counter -le 5 ]; do
echo "Step $counter"
counter=$((counter + 1))
done
$(( )) is arithmetic expansion — it evaluates integer math
expressions. Common operations: +, -, *, /, and
$((total * 100 / count)) for integer percentages.
A common pattern is keeping running counts across loop iterations:
passed=0
failed=0
# ... inside loop:
passed=$((passed + 1))
Write batch_check.sh that:
for f in *.sh to loop over every .sh file.head -1 "$f" and checks
whether it equals #!/bin/bash.✓ filename if correct, ✗ filename (missing shebang)
if not.Checked N files: P passed, F failedTip: store the first line in a variable before comparing:
first=$(head -1 "$f")Then:if [ "$first" = "#!/bin/bash" ]; then
chmod +x batch_check.sh
./batch_check.sh
#!/bin/bash set -e # Check all .sh files for a proper shebang line
As scripts grow, repeating the same logic in multiple places becomes a maintenance problem. Functions let you name a block of code and call it anywhere, changing it in one place when needed.
greet() {
local name="$1"
echo "Hello, ${name}!"
}
greet "engineer" # → Hello, engineer!
greet "world" # → Hello, world!
Functions are defined first, then called by name — just like external commands, but without a separate file.
local variables: Without local, variables set inside a
function leak into the rest of the script, overwriting outer
variables with the same name. Always use local for variables
that belong only to the function.
Arguments: Functions receive $1, $2, etc. independently
of the script’s own arguments — each call gets its own set.
Return values: return 0 means success, return 1 means
failure. This lets callers use functions directly in if:
if is_number "$input"; then
echo "Valid number"
fi
Capturing output: Use $(func_name arg) to capture what a
function prints, just like with any command:
upper=$(to_upper "hello") # upper="HELLO"
Write toolkit.sh with three utility functions:
to_upper — converts its argument to uppercase.
Hint: echo "$1" | tr '[:lower:]' '[:upper:]'file_ext — prints the file extension of its argument.
Hint: echo "${1##*.}" — ##*. strips everything up to the
last dot.is_number — exits 0 if the argument is a valid integer,
1 otherwise.
Hint: [[ "$1" =~ ^-?[0-9]+$ ]] (a Bash regex test)After defining the functions, add a demo section that calls each one and prints the results.
chmod +x toolkit.sh
./toolkit.sh
#!/bin/bash set -e # A small library of utility functions
Three tools handle most text-processing work in shell scripts. Knowing when to reach for each one is the key skill.
| Tool | Best for |
|---|---|
grep |
Selecting lines that match a pattern |
sed |
Transforming text (find & replace, delete lines) |
awk |
Structured data: columns, math, aggregation |
A useful heuristic: grep to filter rows, sed to edit them, awk to compute across them.
grep "pattern" file # Lines matching pattern
grep -i "pattern" file # Case-insensitive
grep -c "pattern" file # Count of matching lines
grep -v "pattern" file # Lines NOT matching (invert)
grep -E "pat1|pat2" file # Extended regex: OR
sed 's/old/new/' file # Replace first match per line
sed 's/old/new/g' file # Replace ALL matches (global flag)
sed '/pattern/d' file # Delete matching lines
sed -n '5,10p' file # Print only lines 5–10
The s/old/new/ syntax is a substitution expression. The /
characters are delimiters — you can use any character if your
pattern contains /: e.g. s|/old/path|/new/path|.
awk splits each line into fields ($1, $2, …) and lets you
run a program on each one:
awk '{print $1}' file # First field of every line
awk -F, '{print $2}' file.csv # Comma-delimited: field 2
awk '$3 == 404 {print $2}' file # Print field 2 where field 3 is 404
awk '{sum += $4} END {print sum / NR}' file # Average of field 4
The END block runs once after all lines — useful for totals and
averages. NR is the total number of records (lines) processed.
access.log is provided. Write log_analysis.sh that:
awk '{print $1}' | sort | uniq -cawk '$3 == 404 {print $2}'ms suffix with sed 's/ms//' before summing.chmod +x log_analysis.sh
./log_analysis.sh
GET /index.html 200 12ms POST /api/login 200 85ms GET /about.html 200 8ms GET /api/users 500 340ms POST /api/data 201 120ms GET /missing-page 404 3ms GET /index.html 200 10ms DELETE /api/users/5 200 45ms GET /old-link 404 2ms POST /api/login 401 30ms GET /api/users 200 55ms PUT /api/users/3 200 90ms GET /favicon.ico 404 1ms GET /index.html 304 5ms POST /api/upload 413 15ms
#!/bin/bash set -e # Analyze access.log using grep, sed, and awk log="access.log"
When you need to check one variable against many possible values,
a chain of if/elif becomes hard to scan. case is cleaner:
case "$input" in
start) echo "Starting..." ;;
stop) echo "Stopping..." ;;
restart) echo "Restarting..." ;;
*) echo "Unknown: $input" ;;
esac
Each branch ends with ;;. The * pattern is the catch-all default.
esac is case spelled backwards — a quirky Bash tradition.
case also supports glob patterns in branches:
case "$filename" in
*.sh) echo "Shell script" ;;
*.py) echo "Python file" ;;
*.txt|*.md) echo "Text file" ;;
esac
Every command exits with a number. 0 always means success; any other value means failure. This is the opposite of most programming languages (where 0 is falsy) — it trips people up at first.
exit 0 # success
exit 1 # general error
exit 2 # misuse / wrong arguments (a common convention)
Exit codes are what make scripts composable. Other scripts and CI systems check your exit code to decide what to do next:
if ./health_check.sh logfile.txt; then
echo "All clear"
else
echo "Problems found"
fi
Write service.sh — a simulated service controller. Use functions
for the start, stop, and status logic, then dispatch with
a case statement on $1.
Requirements:
start — prints Starting service..., exits 0.stop — prints Stopping service..., exits 0.status — checks if /tmp/my_service.pid exists.
If yes: Service is running, exit 0.
If no: Service is stopped, exit 1.restart — calls your stop and start functions.chmod +x service.sh
./service.sh start
./service.sh status
./service.sh # should print usage
#!/bin/bash set -e # A service controller using case and exit codes
The shell expands patterns before commands ever run. Understanding this explains many surprising behaviors.
These look similar but work in completely different contexts:
| Feature | Globs | Regular Expressions |
|---|---|---|
| Used by | Shell, ls, cp, for loops |
grep -E, sed, awk |
* means |
Any string of characters | Zero or more of the preceding |
. means |
A literal dot | Any single character |
+ means |
A literal + |
One or more of the preceding |
Key insight: globs match filenames in the shell before the command runs. Regexes match text content inside a running program.
*.sh # any filename ending in .sh
file?.txt # file1.txt, fileA.txt (exactly one character)
[0-9]* # anything starting with a digit
If no files match a glob, the shell passes the literal pattern string to the command — usually causing a “file not found” error.
Brace expansion generates lists and happens before glob expansion. It does not require matching files to exist:
echo {A,B,C} # → A B C
echo file{1..5}.txt # → file1.txt file2.txt ... file5.txt
mkdir -p project/{src,tests,docs} # three directories at once
| Pattern | Meaning |
|---|---|
. |
Any single character |
+ |
One or more of the preceding |
^ |
Start of line |
$ |
End of line |
[0-9]{3} |
Exactly 3 digits |
4[0-9]{2} |
4xx (any 3-digit number starting with 4) |
Write organizer.sh that:
touch report_{jan,feb,mar}.csv log_{jan,feb,mar}.txt notes.mdfor loop with a glob to list all .csv files.grep -E on access.log to print lines where the URL
starts with /api/.grep -E on access.log to print lines with 4xx status
codes (hint: 4[0-9]{2}).rm at the end.chmod +x organizer.sh
./organizer.sh
#!/bin/bash set -e # Practice with wildcards, brace expansion, and regex
You now have all the core building blocks. These final exercises give you less scaffolding — focus on designing the solution before writing any code.
<(...)Some commands expect filenames, not pipes. Process substitution makes the output of a command look like a file:
diff file1.txt file2.txt # compare two files
diff <(sort file1.txt) <(sort file2.txt) # compare sorted versions
Without process substitution, you would create two temporary files, sort into them, diff them, then delete them. Process substitution handles all of that automatically.
tee writes its input to a file and passes it through to stdout:
grep "ERROR" server_log.txt | tee errors.txt | wc -l
# ↑ saves to file ↑ also counts
Useful when you want to save an intermediate result for debugging without breaking the pipeline.
When diff compares two inputs, lines prefixed with < come from
the first source, and lines prefixed with > come from the second.
This tells you which entries are unique to each side.
Write compare_logs.sh that accepts two log filenames as arguments:
diff to compare the sorted,
unique error messages from each file. Extract just the message
text (everything after the log level keyword).server_log2.txt is provided for testing:
chmod +x compare_logs.sh
./compare_logs.sh server_log.txt server_log2.txt
09:01:00 INFO server started on port 8080 09:02:15 ERROR failed to process request /api/users 09:05:30 ERROR disk full on /var/log 09:06:00 WARN high memory usage detected (85%) 09:08:12 ERROR authentication service unreachable 09:10:00 INFO health check passed
#!/bin/bash set -e # Compare error messages between two log files
Time to combine everything into a real tool. No starter code — design the script yourself.
Write monitor.sh — a log-monitoring tool that analyzes
server_log.txt and produces a complete status report.
Requirements:
server_log.txt).
Validate it exists.=== Log Monitor Report ===Sketch your structure in comments first, then fill it in:
grep -c for counts per levelhead -1 / tail -1 for first and last timestampsgrep "ERROR" piped to awk or cut for error detailsawk for integer percentage: $((errors * 100 / total))print_summary,
print_errors, print_recommendationschmod +x monitor.sh
./monitor.sh
#!/bin/bash set -e # Log monitoring tool — design and implement from scratch
You have learned variables, quoting, pipes, conditionals, loops, functions, text processing, and exit codes. Now combine them all into a production-quality tool.
Build dashboard.sh — a system health dashboard that gathers
information and produces a formatted report. No starter code.
No hints. Design it yourself.
Real command-line tools accept flags like -f file and -o output.
The idiomatic Bash pattern uses a while loop with case:
while [ $# -gt 0 ]; do
case "$1" in
-f) logfile="$2"; shift 2 ;;
-o) outfile="$2"; shift 2 ;;
-h) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
esac
done
shift N discards the first N positional parameters, sliding
subsequent arguments into $1, $2, etc. for the next iteration.
usage() function printed with -h.-f <logfile>, -o <outfile>, -h.HEALTHY — 0 errors (exit 0)DEGRADED — error rate < 30% (exit 1)CRITICAL — error rate ≥ 30% (exit 2)-o is given, write the report to that file; otherwise
print to stdout.chmod +x dashboard.sh
./dashboard.sh
./dashboard.sh -f server_log.txt -o report.txt
cat report.txt
#!/bin/bash set -e # System health dashboard — your capstone project