Skip to main content

Bash Shell Scripting Basics for Beginners

Bash shell scripting basics with tested examples: variables, quoting, if and case, loops, functions, arguments, exit codes, error handling and ShellCheck.

A shell script is a text file full of the commands you would otherwise type by hand. Scripts turn a ten-step procedure into one command, run the same way every time, and work at 3 a.m. from a timer without you. They glue the rest of a Linux system together: deployment steps, backups, health checks, container entrypoints, CI jobs.

This part teaches Bash scripting from the first line to a complete backup script that Part 10 runs on a schedule. Every script here was run as shown, and the finished backup script passes ShellCheck without warnings.

Prerequisites

  • The command line from Part 3 and permissions from Part 4.
  • A text editor (nano is fine) and Bash 4 or newer. Ubuntu 24.04 has Bash 5.2, Debian 13 has 5.2, Fedora 44 has 5.3; macOS still ships Bash 3.2, so install a newer one with Homebrew there.
  • A fresh ~/lab from Part 3, which some scripts read.

The output comes from Ubuntu 24.04.5 LTS (Bash 5.2.21). Create a directory for the scripts with mkdir -p ~/scripts, and save each one there under the name in its first comment line, for example with nano ~/scripts/hello.sh.

Your First Script

#!/usr/bin/env bash
# hello.sh: print a greeting and some facts about this machine

echo "Hello, $USER!"
echo "Today is $(date +%A), and this is $(hostname)."
echo "You are in $PWD."

The first line is the shebang. It tells the kernel which interpreter runs the file. #!/usr/bin/env bash finds bash on the PATH, which also works on systems where Bash is not in /bin; #!/bin/bash is just as common on Linux. Lines that start with # are comments.

A script needs the execute bit (chmod +x) to run as ./hello.sh. You can also run any script with bash hello.sh, which ignores the execute bit and the shebang.

Warning

sh is not Bash. On Debian and Ubuntu, /bin/sh is dash, a small POSIX shell without arrays, [[ ]] or {1..5}. Running a Bash script with sh script.sh fails in confusing ways. Use a #!/usr/bin/env bash shebang and run the script directly. See the difference between sh and Bash.

Variables and Quoting

Assign with name=value, with no spaces around =, and read with $name or ${name}:

#!/usr/bin/env bash
app="notes site"
port=8080
release=$(date +%Y%m%d)          # command substitution: the output of a command
next_port=$((port + 1))          # arithmetic

echo "Deploying ${app} release ${release} on port ${port}"
echo "The next free port is ${next_port}"
echo "Unset variables are empty: [${missing}]"
echo "Or use a default: ${LOG_LEVEL:-info}"

The sed just hides today's date. ShellCheck flags the ${missing} line (SC2154, a variable that is used but never assigned), which is exactly the typo this example shows. ${LOG_LEVEL:-info} uses info when LOG_LEVEL is unset or empty; ${VAR:?message} stops the script with an error instead, which is the safe choice for values a script cannot work without.

Always Quote Your Variables

When a variable is not in double quotes, the shell splits its value on spaces and expands wildcards in it. That is the most common bug in shell scripts:

Without quotes, touch received two arguments and created two files. Write "$var" every time, unless you know you need the splitting. Single quotes keep everything literal: '$HOME' prints $HOME.

Environment Variables and export

A plain variable exists only in the current shell. export makes it part of the environment, which child processes (including scripts you start) inherit:

Arguments

A script receives its command-line arguments in $1, $2 and so on:

Variable Contains
$0 The name the script was called with
$1 ... $9, ${10} The positional arguments
$# How many arguments there are
"$@" All arguments, each one kept as a separate word (use this)
"$*" All arguments joined into one string, separated by the first character of IFS (a space)
$? Exit code of the last command
$$ PID of the script
#!/usr/bin/env bash
echo "script: $0, argument count: $#"
echo "first: $1, second: $2"
for arg in "$@"; do
    echo "  arg: [$arg]"
done
shift                      # drop $1, move the rest down
echo "after shift, first is: $1"

"$@" kept notes site together as one argument. $@ without quotes, or $*, would have split it.

Conditions: if, test and case

if runs a command and checks its exit code: 0 is true, anything else is false. [[ ... ]] is Bash's test command:

#!/usr/bin/env bash
# check.sh: describe a path given as the first argument
path="${1:?usage: check.sh PATH}"

if [[ -d "$path" ]]; then
    if [[ ! -r "$path" || ! -x "$path" ]]; then
        echo "$path is a directory, but you may not list it" >&2
        exit 1
    fi
    entries=$(find -H "$path" -mindepth 1 -maxdepth 1 -printf . | wc -c)   # one dot per entry; -H follows a symlinked path
    echo "$path is a directory with $entries entries"
elif [[ -f "$path" ]]; then
    if [[ ! -r "$path" ]]; then
        echo "$path is a regular file, but you may not read it" >&2
        exit 1
    fi
    size=$(stat -L -c %s -- "$path")                 # -L: the file a symlink points to
    if (( size > 1024 )); then
        echo "$path is a readable file, larger than 1 KiB ($size bytes)"
    else
        echo "$path is a readable file of $size bytes"
    fi
elif [[ -e "$path" ]]; then
    echo "$path exists but is not a regular file or directory"
else
    echo "$path does not exist" >&2
    exit 1
fi

The tests you will use most:

Test True when
-e path the path exists
-f path / -d path it is a regular file / a directory
-r, -w, -x path you may read / write / execute it
-s path the file exists and is not empty
-z "$s" / -n "$s" the string is empty / not empty
"$a" == "$b", != strings are equal / different
"$s" == deploy-* the string matches a glob pattern (inside [[ ]] only)
"$s" =~ ^[0-9]+$ the string matches a regular expression (inside [[ ]] only)
-eq, -ne, -lt, -le, -gt, -ge integer comparisons, or use (( a > b ))
a && b, a || b, ! a and, or, not

[ ... ] (the older test command) also works and is POSIX, but it needs more quoting and has no pattern matching. In Bash scripts, prefer [[ ]] for strings and files and (( )) for numbers.

case compares one value against several patterns, which reads better than a long elif chain:

#!/usr/bin/env bash
case "${1:-}" in
    start|up)     echo "starting" ;;
    stop|down)    echo "stopping" ;;
    restart)      echo "restarting" ;;
    status)       echo "running" ;;
    *)            echo "usage: $0 {start|stop|restart|status}" >&2; exit 2 ;;
esac

Loops

#!/usr/bin/env bash
# over a list of words
for env in dev staging prod; do
    echo "deploying to $env"
done

# over files: the glob keeps names with spaces intact
for page in ~/lab/site/*.html; do
    echo "page: $(basename "$page") ($(wc -c < "$page") bytes)"
done

# over a range, and C-style
for i in {1..3}; do echo -n "$i "; done; echo
for (( i = 10; i > 0; i -= 4 )); do echo -n "$i "; done; echo

# until: retry until something succeeds, with a limit
attempt=1
until [[ -f /tmp/ready ]] || (( attempt > 3 )); do
    echo "waiting for /tmp/ready (attempt $attempt)"
    (( attempt == 2 )) && touch /tmp/ready
    (( attempt++ ))
done

# read a file line by line
while IFS= read -r line; do
    [[ -z "$line" || "$line" == \#* ]] && continue    # skip blanks and comments
    echo "server: $line"
done <<'EOF'
# web servers
web-1.example.com

web-2.example.com
EOF

Two habits prevent most loop bugs:

  • Loop over globs, not over ls. for f in *.log handles every file name; for f in $(ls *.log) splits names with spaces.
  • Read lines with while IFS= read -r line. IFS= keeps leading spaces and -r keeps backslashes. for line in $(cat file) loops over words, not lines.

break leaves a loop and continue jumps to the next round. If a glob matches nothing, Bash passes the pattern itself (*.log) to the loop; add shopt -s nullglob at the top of the script to get no iterations instead.

Functions

A function groups commands under a name. It takes arguments like a script ($1, "$@") and returns an exit code; to return data, print it and capture the output with $(...).

#!/usr/bin/env bash

log() {
    # write timestamped messages to standard error, so stdout stays clean
    printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*" >&2
}

is_port() {
    local value="$1"                       # local: only visible inside the function
    # digits only and no leading zero: Bash reads 080 as a (broken) octal number
    [[ "$value" =~ ^[1-9][0-9]{0,4}$ ]] && (( value <= 65535 ))
}

file_age_days() {
    local now modified
    now=$(date +%s)
    modified=$(stat -c %Y -- "$1") || return 1
    echo $(( (now - modified) / 86400 ))
}

for p in 8080 70000 080 abc; do
    if is_port "$p"; then log "$p is a valid port"; else log "$p is NOT a valid port"; fi
done
echo "access.log is $(file_age_days ~/lab/logs/access.log) days old"

Declare variables inside functions with local; otherwise they change the global variable of the same name. Keep messages for humans on standard error (>&2), so a caller can capture the real output with $(...) without the noise.

Arrays and String Operations

#!/usr/bin/env bash
servers=("web-1" "web-2" "db-1")
servers+=("cache-1")
echo "count: ${#servers[@]}, first: ${servers[0]}, all: ${servers[*]}"

declare -A port=([web]=80 [db]=5432 [cache]=6379)     # associative array (Bash 4+)
for name in web db cache; do echo "$name listens on ${port[$name]}"; done

path="/var/backups/site/site-20261023-0900.tar.gz"
echo "file:      ${path##*/}"        # strip everything up to the last /
echo "directory: ${path%/*}"         # strip from the last / to the end
echo "no suffix: ${path%%.*}"        # strip from the first . to the end
echo "replace:   ${path/site-/www-}"
name="Notes Site"
echo "lower: ${name,,}  upper: ${name^^}  length: ${#name}  slice: ${name:0:5}"

These parameter expansions run inside Bash, so they are much faster than calling basename, dirname, sed or tr in a loop.

Exit Codes and Error Handling

By default, Bash keeps going after a command fails. For most scripts, that is the wrong default. Start them with strict mode:

set -euo pipefail
  • -e: exit as soon as a command fails (with exceptions: inside if, while, && and || conditions).
  • -u: treat an unset variable as an error instead of an empty string.
  • -o pipefail: a pipeline fails if any command in it fails, not only the last one.
#!/usr/bin/env bash
echo "without pipefail:"
grep "pattern" /no/such/file | sort
echo "  pipeline exit code: $?"

set -o pipefail
echo "with pipefail:"
grep "pattern" /no/such/file | sort
echo "  pipeline exit code: $?"

set -eu
echo "with -u, a typo in a variable name stops the script:"
echo "deploying to $DEPLOY_TARGT"
echo "this line never runs"

set -e has surprising corners: a failure inside a function called from an if is ignored, and local var=$(cmd) hides the exit code of cmd. Treat strict mode as a safety net, and still check the commands that matter explicitly. See what set -e means in a Bash script for the details.

To clean up temporary files however a script ends, use trap with a temporary directory from mktemp:

#!/usr/bin/env bash
set -euo pipefail

workdir=$(mktemp -d)
trap 'rm -rf "$workdir"; echo "cleaned up $workdir" >&2' EXIT   # runs on any exit

echo "working in $workdir"
cp ~/lab/logs/access.log "$workdir/"
grep -c '" 404 ' "$workdir/access.log"
false                                 # a failing command: set -e exits here
echo "not reached"

The EXIT trap ran after false stopped the script. Use the same pattern to release locks, stop helper processes or print a "failed at step X" message.

Options With getopts

getopts parses short options such as -v and -n 5, so your script behaves like other Linux commands:

#!/usr/bin/env bash
set -euo pipefail

usage() { echo "usage: $0 [-v] [-n COUNT] NAME" >&2; exit 2; }

verbose=false
count=1
while getopts ":vn:h" opt; do
    case "$opt" in
        v) verbose=true ;;
        n) count="$OPTARG" ;;
        h) usage ;;
        :) echo "option -$OPTARG needs a value" >&2; usage ;;
        *) echo "unknown option -$OPTARG" >&2; usage ;;
    esac
done
shift $((OPTIND - 1))          # what is left are the positional arguments
(( $# == 1 )) || usage

[[ "$count" =~ ^(0|[1-9][0-9]{0,3})$ ]] || { echo "COUNT must be a whole number from 0 to 9999" >&2; exit 2; }
$verbose && echo "greeting $1, $count time(s)" >&2
for (( i = 1; i <= count; i++ )); do
    echo "Hello, $1"
done

getopts handles only single-letter options. For long options (--count 5), loop over "$@" with a case statement, as the backup script below does. See parsing command-line arguments in Bash for more patterns.

Debugging and Linting

Three tools catch most problems before they bite:

  • bash -n script.sh checks the syntax without running anything.
  • bash -x script.sh (or set -x inside the script) prints each command, with variables expanded, before it runs.
  • ShellCheck reads a script and explains likely bugs, from missing quotes to commands that cannot work. Install it with sudo apt install shellcheck (Ubuntu 24.04 ships version 0.9.0; the run below uses 0.11.0) or sudo dnf install ShellCheck.

Here is a short script with typical mistakes:

#!/bin/bash
# cleanup.sh: delete old .log files in a directory (buggy on purpose)
dir=$1
cd $dir
for f in $(ls *.log); do
    if [ $f == "keep.log" ]; then continue; fi
    rm $f
done
echo "Deleted logs in $dir"

Each warning has a code; https://www.shellcheck.net/wiki/SC2164 explains SC2164, and so on. The cd warning is the dangerous one: if cd fails, the loop deletes .log files in whatever directory the script happens to be in. Run ShellCheck in your editor and in CI, so every script gets checked before it ships.

A Complete Script: Back Up the Lab Site

This script puts the pieces together. It archives a directory into a timestamped .tar.gz, keeps only the newest N archives, logs what it does, and fails loudly. Part 10 runs it every night with a systemd timer.

#!/usr/bin/env bash
# backup-site: archive a directory and keep the newest N archives.
# usage: backup-site [--keep N] SOURCE_DIR BACKUP_DIR
set -euo pipefail

keep=7

log() { printf '%s backup-site: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
usage() { echo "usage: ${0##*/} [--keep N] SOURCE_DIR BACKUP_DIR" >&2; exit 2; }

while (( $# > 0 )); do
    case "$1" in
        --keep)
            [[ "${2:-}" =~ ^[1-9][0-9]{0,3}$ ]] || die "--keep needs a number from 1 to 9999"
            keep="$2"; shift 2 ;;
        -h|--help) usage ;;
        -*) die "unknown option: $1" ;;
        *) break ;;
    esac
done
(( $# == 2 )) || usage

# remove trailing slashes, so "site/" and "site//" are treated like "site"
strip_slashes() {
    local path="$1"
    while [[ "$path" == */ && "$path" != / ]]; do path="${path%/}"; done
    printf '%s' "$path"
}
source_dir="$(strip_slashes "$1")"
backup_dir="$(strip_slashes "$2")"
if [[ -L "$source_dir" ]]; then
    die "source is a symlink; give the real directory: $source_dir"
fi
[[ -d "$source_dir" ]] || die "source directory not found: $source_dir"
# a backup directory inside the source would end up in every new archive
source_real="$(realpath -- "$source_dir")"
backup_real="$(realpath -m -- "$backup_dir")"
case "$backup_real/" in
    "$source_real"/*) die "the backup directory must not be inside the source: $backup_dir" ;;
esac
mkdir -p "$backup_dir"

# one run at a time per backup directory
exec 9> "$backup_dir/.backup-site.lock"
flock -n 9 || die "another backup is running in $backup_dir"

name="$(basename "$source_dir")"
archive="$backup_dir/$name-$(date -u +%Y%m%d-%H%M%S).tar.gz"
if [[ -e "$archive" ]]; then
    die "archive already exists, try again in a second: $archive"
fi
partial="$(mktemp "$backup_dir/.$name-XXXXXX.partial")"
trap 'rm -f "$partial"' EXIT          # never leave a half-written archive behind

log "archiving $source_dir to $archive"
tar -czf "$partial" -C "$(dirname "$source_dir")" -- "$name"
mv "$partial" "$archive"
log "created $(du -h "$archive" | cut -f1) archive"

# only archives this script made (NAME-YYYYMMDD-HHMMSS.tar.gz); glob results come oldest first
archives=("$backup_dir/$name"-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9].tar.gz)
excess=$(( ${#archives[@]} - keep ))
for (( i = 0; i < excess; i++ )); do
    rm -f -- "${archives[i]}"
    log "removed old archive ${archives[i]##*/}"
done
log "done, $(( excess > 0 ? keep : ${#archives[@]} )) archive(s) kept"

Install it for your user so it can be run from anywhere: mkdir -p ~/.local/bin && cp backup-site ~/.local/bin/. For a system-wide tool that a service runs, sudo install -m 0755 backup-site /usr/local/bin/ is the usual place.

A few details are worth copying into your own scripts:

  • The archive is written to a uniquely named .partial file from mktemp and renamed only when tar succeeded, so a half-written file never looks like a good backup. The EXIT trap removes the partial file on failure.
  • flock on a lock file lets only one run work in a backup directory at a time, and the script refuses to overwrite an archive with the same name, or to write backups inside the directory it archives.
  • A symlink as the source is refused: tar would archive the link, not the files behind it. Trailing slashes are removed first, so site// cannot slip past that check.
  • -- before the directory name tells tar that no options follow, so a directory called --help is archived instead of being read as an option. --keep is limited to four digits, because Bash arithmetic silently wraps around on huge numbers.
  • All messages go to standard error with a UTC timestamp, which reads well in the systemd journal.
  • The pruning pattern matches only names this script creates, so another site's site-assets-... archives in the same directory are left alone. Better still, give each source its own backup directory. The UTC timestamp in the name sorts in time order, and Bash sorts glob results, so the array is oldest first and needs no ls parsing.
  • Bad input exits with code 2 (usage) or 1 (error), which systemd and CI tools report as a failure.

Other Shells

  • Zsh is the default interactive shell on macOS and popular on Linux with frameworks such as Oh My Zsh. It runs most Bash syntax, with differences: arrays start at index 1 (Bash starts at 0) and unquoted variables are not split on spaces. Scripts still run with whatever their shebang says, so a #!/usr/bin/env bash script works the same from Zsh.
  • Fish has friendly autosuggestions and its own syntax (set name value, end instead of done and fi). It is not POSIX-compatible, so keep scripts in Bash even if you use Fish interactively.
  • POSIX sh (dash on Debian and Ubuntu, ash in Alpine and BusyBox images) is the lowest common denominator. Write #!/bin/sh scripts only when they must run where Bash is missing, for example in minimal container images, and check them with shellcheck -s sh.

When Not to Use a Shell Script

Bash is excellent for gluing commands together. Once a script needs data structures, JSON or HTTP APIs, real error handling or more than a couple of hundred lines, a language such as Python or Go is easier to test and maintain. For configuring many servers the same way, use a configuration tool such as Ansible instead of SSH loops.

Common Mistakes

  • Unquoted variables (rm $file). Quote everything: "$file".
  • Spaces around = in assignments.
  • Parsing ls output in loops instead of using globs.
  • cd without a check in a script that deletes files. Use cd "$dir" || exit 1, or set -e.
  • Running a Bash script with sh.
  • Windows line endings. A script saved with CRLF fails with /usr/bin/env: 'bash\r': No such file or directory. Convert it with sed -i 's/\r$//' script.sh or dos2unix.
  • Secrets in scripts. Read them from the environment or a file with restricted permissions, never hard-code them in a file that goes into Git.

Summary

  • Start scripts with #!/usr/bin/env bash, make them executable, and add set -euo pipefail.
  • Quote every variable, use "$@" for arguments, and local inside functions.
  • Use [[ ]] for tests, (( )) for arithmetic, case for many choices, globs for files, and while IFS= read -r for lines.
  • Return status with exit codes, data with output, and send messages to standard error.
  • Clean up with trap ... EXIT, debug with bash -x, and lint with ShellCheck.

Next: Linux System Administration Basics, where the backup script runs every night as a systemd timer.

Keep Learning

Updated:

Found an issue?