Skip to content
ioob.dev
Go back

Linux Basics Part 8 — Bash Scripting Basics

· 9 min read
Linux Series (8/8)
  1. Linux Basics Part 1 — The Shell and Filesystem Structure
  2. Linux Basics Part 2 — File Permissions and Users/Groups
  3. Linux Basics Part 3 — Processes and Signals
  4. Linux Basics Part 4 — Text Processing and Pipes
  5. Linux Basics Part 5 — Network Tools
  6. Linux Basics Part 6 — Systemd and Service Management
  7. Linux Basics Part 7 — Package Management
  8. Linux Basics Part 8 — Bash Scripting Basics
Table of contents

Table of contents

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.

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.

  1. Double quotes "..." — Allows variable expansion, preserves spaces. Use this most of the time
  2. Single quotes '...' — No expansion, literal as-is. For fixed strings
  3. 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:

ExpressionMeaning
[[ "$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:

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:

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:

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.

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:

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.


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Linux Basics Part 7 — Package Management
Next Post
Networking Fundamentals Part 1 — OSI and TCP/IP Models