Table of contents
- Why Write Scripts
- First Script — Setting Up the Skeleton
- Variables and Quoting — The Three Big Traps
- Conditionals — Use
[[ ... ]] - Loops — for / while
- Functions — The Smallest Unit of Reuse
- Exit Codes — Success and Failure as Numbers
- How set Options Actually Work
- Practical Example 1 — Backup Script
- Practical Example 2 — Disk and Memory Monitoring
- Debugging — bash -x and trap
- ShellCheck — Keep a Linter by Your Side
- The Point Where You Move Beyond Shell — Where to Stop
- Wrapping Up the Linux Basics Series
Why Write Scripts
If you’ve followed the series up to this point, you probably have a good number of commands committed to muscle memory. apt update && apt upgrade, systemctl restart nginx, journalctl -u myapp -f, curl -v http://.... If you had to type these commands in sequence every morning, eventually someone would skip a line. That’s why we write scripts.
A Bash script starts as “the exact same commands you’d type in the terminal, collected into a file.” Add variables, conditionals, loops, and functions on top, then attach failure handling and debugging options, and you have the foundation for operational automation.
This post aims to bring someone who has “never written a script before” to the point where they can write scripts that run safely. It’s less of a syntax reference and more of a collection of commonly stepped-on mines and the idioms to avoid them.
flowchart TB
A["Commands you type daily"] --> B["Bundle into a file (.sh)"]
B --> C["Make flexible with variables & quoting"]
C --> D["Add logic with conditionals & loops"]
D --> E["Reuse with functions"]
E --> F["Make safe with exit codes & set -e"]
F --> G["Debug with bash -x"]
First Script — Setting Up the Skeleton
Every script starts similarly.
#!/usr/bin/env bash
set -euo pipefail
echo "Hello, scripting."
Let’s break it down line by line.
#!/usr/bin/env bash: The shebang. When this file is executed,envfindsbashin$PATHand uses it as the interpreter. You can also write#!/bin/bash, but on macOS or some minimal containers where the path differs, theenvapproach is more portableset -e: Immediately terminate the script if any command failsset -u: Error on use of undefined variablesset -o pipefail: Treat the entire pipeline as failed if any stage fails
set -euo pipefail is an idiom that goes on virtually the first line of every “production” script. The detailed reasons are explored one by one below. These three lines alone block most of the major accidents that beginners’ scripts are prone to.
Create the file.
cat > hello.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
echo "Hello, scripting."
EOF
chmod +x hello.sh
./hello.sh
You need chmod +x for execute permission so ./hello.sh works. You can always run it explicitly with bash hello.sh without the permission, but if you’re distributing it as a script, adding execute permission is the convention.
Variables and Quoting — The Three Big Traps
The most common bug in Bash is quoting. If you don’t properly wrap variables, the moment spaces and special characters enter the picture, the script breaks. This section alone eliminates half your bugs.
name="ioob"
greeting="Hello, $name" # Variable expansion
echo "$greeting" # Always wrap variables in double quotes
Three quoting rules to establish first.
- Double quotes
"..."— Allows variable expansion, preserves spaces. Use this most of the time - Single quotes
'...'— No expansion, literal as-is. For fixed strings - No quotes — Splits on whitespace and expands wildcards. The root cause of most bugs
A path with spaces is the most effective way to feel the difference.
dir="/tmp/my data"
# Bad example — splits on the space and gets passed as two arguments
rm -rf $dir # rm -rf /tmp/my data — tries to delete "data" file
# Good example
rm -rf "$dir" # Passed as a single path
This difference directly leads to accidents where someone else’s files get deleted, so the habit of always wrapping variables in double quotes is a good one.
Variable Expansion Idioms
Bash has some useful shorthand for variable expansion. Here are the commonly used ones.
# Default value — "default" if the variable is empty
echo "${NAME:-default}"
# Default assignment — assign default if the variable is empty
: "${NAME:=default}"
echo "$NAME" # default
# Required — error and exit if the variable is empty
echo "${NAME:?NAME is required}"
# Length
s="hello"; echo "${#s}" # 5
# Prefix/suffix removal
path="/var/log/app.log"
echo "${path%.log}" # /var/log/app
echo "${path##*/}" # app.log (like basename)
echo "${path%/*}" # /var/log (like dirname)
${VAR:-default} means “use the variable if it exists, otherwise use the default” — it frequently appears in environment branching. ${VAR:?} means “die immediately if missing,” useful for required argument validation.
Command Substitution
To capture a command’s output into a variable, use $(...).
today=$(date +%Y-%m-%d)
echo "Today is $today"
# Nesting works naturally
count=$(wc -l < "$(ls -t log/*.log | head -1)")
The old backtick syntax `...` can’t nest and is hard to read. Settle on $() as the default and move on.
Conditionals — Use [[ ... ]]
Bash conditionals are built from if and test blocks. In modern Bash, [[ ... ]] is the standard. It’s safer and more feature-rich than the older [ ... ] (which is an alias for the test command).
name="ioob"
if [[ "$name" == "ioob" ]]; then
echo "hello, ioob"
elif [[ "$name" == "admin" ]]; then
echo "welcome, admin"
else
echo "who are you?"
fi
Frequently used comparison expressions:
| Expression | Meaning |
|---|---|
[[ "$a" == "$b" ]] | String equality |
[[ "$a" != "$b" ]] | String inequality |
[[ "$a" =~ ^[0-9]+$ ]] | Regex match |
[[ -z "$a" ]] | Empty string |
[[ -n "$a" ]] | Not empty |
(( a == b )) | Numeric comparison |
(( a < b )) | Numeric less than |
[[ -f "$f" ]] | File exists (regular file) |
[[ -d "$d" ]] | Directory exists |
[[ -e "$p" ]] | Path exists (any type) |
[[ -r "$f" ]] | Read permission |
&&, ||, ! | Logical AND/OR/NOT |
Using string comparison for numbers leads to the trap where "10" < "9" evaluates to true. Clearly remember: use (( ... )) for numeric comparison.
count=10
if (( count > 5 )); then
echo "big"
fi
Loops — for / while
Bash for comes in two styles: list iteration and C-style.
# List iteration
for name in alice bob carol; do
echo "hi, $name"
done
# File iteration — globs expand into a list
for f in ./*.log; do
echo "$f"
done
# Numeric range (brace expansion)
for i in {1..5}; do
echo "round $i"
done
# C-style
for (( i=0; i<5; i++ )); do
echo "i=$i"
done
Don’t use ls output to iterate over files. for f in $(ls ...) breaks on filenames with spaces. Using glob patterns directly as shown above is the safe approach.
while repeats as long as the condition is true. The most common pattern is reading a file line by line.
while IFS= read -r line; do
echo "line: $line"
done < /etc/hosts
Using IFS= with -r is an idiom that preserves whitespace and prevents backslash interpretation. Without this combination, reading files with indentation or special characters will produce incorrect results.
Functions — The Smallest Unit of Reuse
Functions are named command blocks. They bundle repeated logic.
log() {
local level="$1"
shift
echo "[$(date +%Y-%m-%dT%H:%M:%S%z)] [$level] $*"
}
require() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
log ERROR "required command not found: $cmd"
exit 1
fi
}
log INFO "script started"
require rsync
require jq
log INFO "all requirements satisfied"
Some practical tips:
- Declare local variables with
local. Without it, they’re global. You’ll silently overwrite outer variables $1,$2… are function arguments.$@is all arguments (preserved as an array),$*is all arguments joined with spaces- A function’s success or failure is communicated via return code.
return 0means success, any other number means failure. If nothing is specified, the exit code of the last command becomes the return value
Exit Codes — Success and Failure as Numbers
Every command returns a number in the range 0-255 upon exit. 0 means success, anything else means failure — this convention holds across all of Linux. You can capture this number with $?.
grep "ERROR" /var/log/syslog
echo "exit code: $?"
# 1 if not found, 0 if found, 2 for I/O errors
The exit code returned by the last line of a script becomes “the success status of the entire script.” CI pipelines, systemd’s Restart=on-failure, &&/|| chains — all depend on this number. So returning 0 on a failure path is wrong.
# Bad example — swallows the failure
grep "ERROR" file.log || true # Continues even if nothing is found
# Good example — explicitly handles the failure
if ! grep "ERROR" file.log >/dev/null; then
log INFO "no errors today"
fi
Only use || true when you intentionally want to ignore a failure. Using it habitually nullifies the effect of set -e.
How set Options Actually Work
If you put set -euo pipefail on the first line, let’s briefly demonstrate how the script’s behavior changes concretely.
set -e — Terminate Immediately on Failure
#!/usr/bin/env bash
set -e
cp nonexistent.txt /tmp/
echo "This line will not be executed"
The moment cp fails, the script stops. Scripts written without -e can “continue to the next line even after an error” and end up in bizarre states.
Exception: set -e does not apply to commands explicitly placed in conditions. Cases like if grep ..., while grep ..., grep ... && ..., grep ... || true. So when you want to treat failure as a condition, wrap it with if.
set -u — Error on Undefined Variable Usage
#!/usr/bin/env bash
set -u
echo "hello, $USERNAME" # Error if the variable is undefined
If you made a typo like $USRENAME, it would normally be treated as an empty string and pass silently. With -u, it’s caught as an error immediately. Use the :- default value syntax together to handle “optional variables.”
set -o pipefail — Catch Failures in the Middle of Pipes
#!/usr/bin/env bash
set -e
set -o pipefail
cat nonexistent.txt | wc -l
# Without pipefail, wc -l outputs 0 and succeeds
# With pipefail, cat's failure propagates as the overall failure
Pipelines by default only look at the last command’s exit code, masking errors in intermediate stages. pipefail prevents this.
trap — Cleanup Hooks on Failure or Exit
trap lets you attach “cleanup when the script dies.” Commonly used for deleting temporary files.
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
echo "work in $tmpdir"
# However it ends (success, failure, Ctrl+C), tmpdir gets deleted
EXIT means “unconditionally when the script terminates,” and ERR means “the moment an error occurs.” Many scripts use both together.
Practical Example 1 — Backup Script
Let’s tie together the elements covered so far into a script that’s actually usable. The first example is a common backup task: dump a database, compress it, upload to remote storage, and clean up old files.
#!/usr/bin/env bash
set -euo pipefail
# --- Configuration ---
DB_NAME="${DB_NAME:-myapp}"
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/db}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"
S3_BUCKET="${S3_BUCKET:-s3://backups/myapp}"
# --- Utilities ---
log() {
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
}
require() {
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 \
|| { log "required command missing: $cmd"; exit 1; }
done
}
# --- Preparation ---
require pg_dump gzip aws
mkdir -p "$BACKUP_ROOT"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
file="$BACKUP_ROOT/${DB_NAME}_${stamp}.sql.gz"
# Clean up local temp files on error or success
trap 'log "backup script exiting with code $?"' EXIT
# --- Dump ---
log "dumping $DB_NAME to $file"
pg_dump "$DB_NAME" | gzip -c > "$file"
# --- Upload ---
log "uploading to $S3_BUCKET"
aws s3 cp "$file" "$S3_BUCKET/"
# --- Local retention cleanup ---
log "cleaning local backups older than $RETENTION_DAYS days"
find "$BACKUP_ROOT" -name "${DB_NAME}_*.sql.gz" -mtime +"$RETENTION_DAYS" -print -delete
log "done"
Here are the strengths of this script:
- Environment variable overrides: Values like
DB_NAMEandBACKUP_ROOThave defaults but can be overridden via env. Reusable across different environments - require helper: Fails early if required commands aren’t present. Prevents “runs all the way through then fails at the end”
- trap: Logs a line on exit. Makes tracking easy in
journalctlwhen connected to systemd set -euo pipefail: If either side ofpg_dump | gzipfails, it’s caught immediately
Combined with a systemd timer, this replaces cron (see Part 6).
Practical Example 2 — Disk and Memory Monitoring
The second example is a simple monitoring script that sends an alert when thresholds are exceeded. In practice you’d use tools like Prometheus or Datadog, but as a lightweight on-hand tool, it still has value.
#!/usr/bin/env bash
set -euo pipefail
DISK_THRESHOLD="${DISK_THRESHOLD:-90}" # %
MEM_THRESHOLD="${MEM_THRESHOLD:-85}" # %
WEBHOOK="${WEBHOOK:-}" # Empty means skip notification
notify() {
local msg="$1"
echo "$msg"
if [[ -n "$WEBHOOK" ]]; then
curl -fsS -X POST -H "Content-Type: application/json" \
-d "{\"text\":\"$msg\"}" "$WEBHOOK" || true
fi
}
# Disk — based on / mount
disk_used=$(df -P / | awk 'NR==2 {gsub("%",""); print $5}')
if (( disk_used >= DISK_THRESHOLD )); then
notify "[WARN] disk usage on / is ${disk_used}% (threshold ${DISK_THRESHOLD}%)"
fi
# Memory — usage percentage
read -r total used <<<"$(free -m | awk '/^Mem:/ {print $2, $3}')"
mem_used=$(( used * 100 / total ))
if (( mem_used >= MEM_THRESHOLD )); then
notify "[WARN] memory usage is ${mem_used}% (threshold ${MEM_THRESHOLD}%)"
fi
A few points worth noting:
df -P: POSIX output. Column alignment doesn’t break, making it easy to parse withawkawk 'NR==2 {gsub("%",""); print $5}': From the second line, strip the%from the fifth column (usage) and print. Applying transformations like this through pipelines is a very common idiomread -r total used <<<"...": A here-string pattern that splits command output into two variables at oncecurl ... || true: Prevents notification failure from failing the entire script. When a delayed alert is less bad than a missed one, this is the right call
Thresholds and webhooks are controlled via environment variables, so the same script can run on multiple servers with different configurations.
Debugging — bash -x and trap
The fastest debugging tool when a script doesn’t behave as expected is bash -x. Each command is printed in its final expanded form.
bash -x ./myscript.sh
# Or at the top of the script
set -x
If the output is too noisy, you can toggle it for specific sections.
set -x
cp -r "$src" "$dst"
set +x
For more refined debugging, trap ... DEBUG / trap ... ERR can record line numbers or stacks. For larger scripts, this idiom is useful:
trap 'echo "[err] line $LINENO: $BASH_COMMAND" >&2' ERR
This prints which line and which command failed to stderr the moment an error occurs. Combined with set -e, tracing the failure point becomes straightforward.
ShellCheck — Keep a Linter by Your Side
Bash has many traps, and reviewing by eye alone easily misses things. ShellCheck is a linter that catches these well. It warns about missing quotes, variable misuse, unused assignments, and patterns that conflict with set -e.
# Local installation
sudo apt install shellcheck # Debian/Ubuntu
sudo dnf install ShellCheck # RHEL family
# Run
shellcheck myscript.sh
Adding a shellcheck step to your CI catches quoting bugs and $ mistakes before code review. If Bash scripts go into production, it’s practically essential.
The Point Where You Move Beyond Shell — Where to Stop
One last practical note. Bash excels at chaining short tasks together, but its limits arrive quickly when logic gets complex.
- Multi-stage JSON processing ->
jqcovers it, but long chains are better in Python - Processing that needs data structures -> Bash has arrays and associative arrays, but they’re uncomfortable
- Scripts exceeding a few hundred lines -> Easier to maintain in Python/Go
Developing a sense for “how far to go with Bash, where to switch to another language” is also a matter of experience. As a rule of thumb, start thinking at 100 lines, and migrate at 300 lines. Bash shines brightest when used as a glue language.
Wrapping Up the Linux Basics Series
From Part 5 through Part 8, we’ve covered the four areas that need to become second nature when using Linux servers in practice. Diagnosing communication status with network tools, declaratively managing processes with systemd, consistently installing tools with package managers, and automating repetitive tasks with Bash scripts.
Laying out these four pillars, you can picture the process of setting up a single server from scratch.
flowchart LR
A["Connect to server (SSH)"] --> B["Install packages<br/>(apt / dnf)"]
B --> C["Register service<br/>(systemd unit)"]
C --> D["Start service<br/>(systemctl enable --now)"]
D --> E["Check logs<br/>(journalctl)"]
E --> F["Operational automation<br/>(Bash + systemd timer)"]
F --> G["Troubleshooting<br/>(curl / ss / dig)"]
G -.-> A
One step further from here lies the world of containers and orchestration. The concepts of processes, networking, and filesystems covered in this series underpin virtually every behavior of Docker and Kubernetes. From Docker’s definition that “isolating a Linux process with namespaces creates a container,” to Kubernetes’s model where “Kubelet manages Pod lifecycle instead of systemd” — the fundamentals you’ve built carry directly forward.
If you’d like to continue reading into the next technology stack, these series are recommended:
- Docker Introduction Part 1 — What is Docker — How Linux processes become containers
- Kubernetes Introduction Part 1 — What is Kubernetes — A system for operating containers across multiple nodes
Nearly every development and operations problem that occurs on Linux is ultimately solved with a combination of these fundamentals. I hope this series remains as a practical instinct you can use when sitting in front of a server.

Loading comments...