Linux Bash Scripting
Write bash scripts that fail loudly instead of silently. Set -euo pipefail, quoting, [[ ]] over [ ], trap for cleanup, and shellcheck.
Why Bash Scripting Matters
Bash scripting is where Linux command-line knowledge becomes automation. A Bash script can validate environment state, deploy an application, rotate logs, back up files, or glue together tools that do one job each.
Bash is not the best language for every task, but it is excellent for lightweight automation that lives close to the operating system.
Starting with the Shebang
A script usually begins with a shebang line that tells the system which interpreter to use.
#!/usr/bin/env bash
Save the script, make it executable, and run it:
chmod +x deploy.sh
./deploy.sh
Variables and Positional Parameters
Variables store values.
name="devops"
env="production"
echo "$name"
Bash also provides positional parameters for command-line arguments.
echo "$1"
echo "$2"
echo "$#"
$1is the first argument$2is the second argument$#is the number of arguments$@represents all arguments
Example:
./backup.sh /var/log nginx
Inside the script, $1 might be /var/log and $2 might be nginx.
Conditionals with if, elif, and else
Use conditionals to make decisions.
if [ -f /etc/nginx/nginx.conf ]; then
echo "Nginx config exists"
elif [ -d /etc/nginx ]; then
echo "Nginx directory exists"
else
echo "Nginx not installed"
fi
Common test operators:
-ffile exists-ddirectory exists-zstring is empty-nstring is not empty
Loops: for and while
A for loop repeats over a list.
for host in app1 app2 app3; do
echo "Checking $host"
done
A while loop repeats while a condition is true.
count=1
while [ "$count" -le 3 ]; do
echo "Attempt $count"
count=$((count + 1))
done
Loops are common in operations scripts that process files, services, or hosts.
Functions
Functions group logic into reusable units.
log_info() {
echo "[INFO] $1"
}
check_file() {
if [ -f "$1" ]; then
echo "Found: $1"
else
echo "Missing: $1"
fi
}
Then call them:
log_info "Starting check"
check_file /etc/hosts
Functions improve readability and reduce duplication.
Arrays and String Operations
Bash supports arrays.
services=(nginx sshd docker)
echo "${services[0]}"
echo "${services[@]}"
Basic string operations are also useful.
filename="app.log"
echo "${filename%.log}"
echo "${filename/app/service}"
These features help with small automation tasks without calling external tools for everything.
Exit Codes
Every command returns an exit code. 0 usually means success. Non-zero means failure.
mkdir testdir
echo $?
In scripts, you often check exit codes directly or stop early on failure.
cp config.yaml config.yaml.bak || exit 1
You can also end your own script with a chosen exit code:
exit 0
exit 1
Standard Input, Output, and Error
Bash scripts interact with three standard streams:
- stdin: input stream
- stdout: normal output
- stderr: error output
Example:
echo "normal message"
echo "error message" >&2
Redirection lets you control where output goes.
./script.sh > output.log 2> error.log
./script.sh < input.txt
This is important when integrating scripts into CI pipelines or scheduled jobs.
Cleaning Up with trap
trap lets you run cleanup code when a script exits or receives a signal.
cleanup() {
echo "Cleaning up"
}
trap cleanup EXIT
This is useful for removing lock files, stopping background jobs, or printing final status messages.
Tip:
trapis a simple way to make scripts safer and more professional.
Practical Example: Argument Validation Script
#!/usr/bin/env bash
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <directory>" >&2
exit 1
fi
target_dir="$1"
if [ ! -d "$target_dir" ]; then
echo "Directory not found: $target_dir" >&2
exit 2
fi
echo "Listing files in $target_dir"
ls -lah "$target_dir"
This script demonstrates positional parameters, conditionals, stderr, and exit codes.
Practical Example: Service Health Check Script
#!/usr/bin/env bash
services=(nginx sshd docker)
for service in "${services[@]}"; do
if systemctl is-active --quiet "$service"; then
echo "$service is running"
else
echo "$service is not running" >&2
fi
done
This type of script is realistic for server health reporting.
Best Practices for Bash Scripts
- quote variables like
"$1"to avoid word-splitting bugs - keep scripts focused and readable
- use functions for repeated logic
- return meaningful exit codes
- send errors to stderr when appropriate
- test scripts with both expected and unexpected input
Bash becomes much easier when you treat it as structured automation, not just a long list of commands.
Test Your Understanding
Exercise 1: Counting script arguments
Question: In the tutorial, what does $# represent inside a Bash script?
Exercise 1: Counting script arguments
Identify what the special parameter $# contains in Bash.
Exercise 2: Writing to stderr
Scenario: Your script wants to print a usage message when no directory argument is provided.
Question: Why does the example use >&2 for that message?
Exercise 2: Writing to stderr
Choose why a Bash script redirects an error message to file descriptor 2.
Exercise 3: Registering exit cleanup
Question: What does trap cleanup EXIT do in the lesson's example?
Exercise 3: Registering exit cleanup
Identify what the trap statement accomplishes when the script exits.
Key takeaways
- Start every script with
set -euo pipefail. Without it, a failed command is ignored and the script carries on. - Quote every variable.
"$file"handles spaces and empty values; bare$filebreaks on both. [[ ]]over[ ]in bash. Safer with empty variables and it supports pattern matching.$?is the exit code of the last command, and 0 means success. Check it or letset -edo it.trap 'cleanup' EXITruns your cleanup whether the script succeeds, fails or is interrupted."$@"preserves arguments with spaces;$*and bare$@do not.- Run it through
shellcheck. It catches most real bugs before you do.
Frequently Asked Questions
What does set -euo pipefail do and why use it?
Four safety changes. -e exits the script as soon as any command fails, instead of blindly continuing. -u treats an unset variable as an error, catching typos and missing arguments rather than silently substituting an empty string. The difference between rm -rf "$DIR/" and rm -rf /. -o pipefail makes a pipeline fail if any command in it fails, not just the last. -x is the optional fourth, echoing each command as it runs, which is invaluable when debugging. Put it on line two of every script.
What is the difference between [ ] and [[ ]] in bash?
[ is a command whose arguments happen to look like syntax, so an unquoted empty variable turns [ $x = "y" ] into [ = "y" ] and it fails with a syntax error. [[ ]] is bash syntax rather than a command: it handles empty variables safely, supports && and || inside, and adds pattern matching ([[ $f == *.txt ]]) and regex (=~). Use [[ ]] in anything with a bash shebang; [ is only needed for strict POSIX sh scripts.
Why do I need to quote variables in bash?
Because unquoted variables are word-split on whitespace and glob-expanded. rm $file with file="my document.txt" tries to delete two files, neither of which exists; rm "$file" deletes the right one. An empty unquoted variable disappears entirely, which can turn a command into something quite different from what you intended. The rule is simple enough to apply without thinking: always "$var", and always "$@" for arguments. shellcheck flags every violation.
How do I check whether a command succeeded?
Every command sets $? to its exit code: 0 for success, non-zero for failure. Test it directly with if command; then, which reads better than comparing $?, or capture it immediately with rc=$? if you need the value, since the next command overwrites it. With set -e the script exits automatically on failure, and the idiom for "this one is allowed to fail" is command || true. In your own scripts, exit 1 on error so callers and CI can detect it.
What is trap and when should I use it?
trap runs a command when the shell receives a signal or exits. The standard use is cleanup: trap 'rm -rf "$tmpdir"' EXIT deletes a temporary directory whether the script finishes normally, fails under set -e, or is interrupted with Ctrl-C. Without it, an early exit leaves temporary files, lock files and half-finished state behind. Trapping EXIT covers all those cases at once, which is why it is preferable to trapping individual signals.
Should I write this in bash or move to Python?
Bash is excellent at running other programs and piping between them, if the script is mostly command invocations and a little control flow, it is the right tool. Move to Python once you need data structures, JSON or arithmetic beyond counting, error handling more nuanced than exit codes, or once the script passes a few hundred lines. The honest signal is associative arrays and nested quoting: by the time you are fighting bash's syntax rather than using it, the rewrite has already paid for itself.