Skip to content

Bash Scripting Fundamentals

Bash scripting turns a sequence of commands you type by hand into a repeatable, reliable tool. This page walks through the core building blocks of Bash and ends with a complete, runnable disk-health-check script you can adapt for real servers.

Tested on

AlmaLinux 9.4 (Bash 5.1) and Ubuntu 22.04 (Bash 5.1). Examples use #!/usr/bin/env bash, so they are portable across both.

The shebang

The first line of a script tells the kernel which interpreter to run. This is the shebang (#!):

#!/usr/bin/env bash

Using /usr/bin/env bash looks up bash on the user's PATH, which is more portable than hard-coding #!/bin/bash (Bash lives in /usr/bin/bash on some systems, /bin/bash on others — though they are usually symlinked).

Bash is not sh

On Debian/Ubuntu, /bin/sh is dash, not Bash. If your script uses Bash-only features (arrays, [[ ]]), use a bash shebang — never #!/bin/sh.

Making a script executable

Create a file and give it the execute bit:

# Create the file
vim hello.sh

# Make it executable for the owner (and group/other read+execute)
chmod +x hello.sh

# Run it
./hello.sh

A minimal first script:

#!/usr/bin/env bash
echo "Hello from $(hostname)"

The ./ prefix is required — the current directory is normally not on your PATH, so hello.sh alone would fail with "command not found".

Variables and quoting

Assign with name=valueno spaces around the =. Reference with $name or, more safely, ${name}:

#!/usr/bin/env bash
name="alma"
greeting="Hello"

echo "$greeting, $name"          # Hello, alma
echo "${greeting}, ${name}!"     # braces make boundaries explicit
echo "${name}_backup.tar.gz"     # without braces, $name_backup would be one name

Quoting controls how Bash expands a value:

Quote Variable expansion? Use for
"double" Yes Almost always — preserves spaces safely
'single' No (literal) Fixed strings, regex, awk programs
none Yes + word-split Risky — avoid for variables
path="/var/log/my app.log"
rm "$path"     # correct: one argument, "/var/log/my app.log"
rm $path       # WRONG: two arguments, "/var/log/my" and "app.log"

Quote every variable

Unless you have a specific reason not to, wrap variable references in double quotes. This single habit prevents the majority of beginner Bash bugs.

Command substitution

Capture the output of a command into a variable with $(...):

today=$(date +%F)            # e.g. 2026-06-07
kernel="$(uname -r)"
free_kb=$(df --output=avail / | tail -1)

echo "Backup for $today on kernel $kernel"

The older backtick form `cmd` works but does not nest cleanly. Prefer $(...).

Positional parameters and exit codes

Arguments passed to a script are $1, $2, and so on. A few special variables:

Variable Meaning
$0 The script name
$1$9 First through ninth argument
$# Number of arguments
$@ All arguments, each separately quoted
$* All arguments as one string
$? Exit status of the last command
$$ PID of the current script
#!/usr/bin/env bash
echo "Script: $0"
echo "First arg: $1"
echo "Arg count: $#"
echo "All args: $@"

Every command returns an exit code: 0 means success, anything else means failure. Check it with $?:

ping -c1 example.com >/dev/null 2>&1
if [ "$?" -eq 0 ]; then
    echo "Host is up"
fi

Usually you test commands directly in if rather than via $? (see below).

Conditionals

if / elif / else

#!/usr/bin/env bash
count=$1

if [ "$count" -gt 100 ]; then
    echo "high"
elif [ "$count" -gt 10 ]; then
    echo "medium"
else
    echo "low"
fi

test, [ ], and [[ ]]

[ ] is the POSIX test command — note the required spaces inside the brackets. [[ ]] is a Bash keyword with extra features (pattern matching, &&/||, no word-splitting):

[ "$a" = "$b" ]            # POSIX, portable
[[ "$a" == "$b" ]]         # Bash; also supports == globs and =~ regex
[[ "$file" == *.log ]]     # glob match — true if $file ends in .log
[[ "$ip" =~ ^[0-9.]+$ ]]   # regex match

Prefer [[ ]] in Bash scripts

It is safer (no word-splitting surprises) and more capable. Use plain [ ] only when you genuinely need POSIX sh portability.

Common test operators

Test True when…
-z "$s" string is empty
-n "$s" string is non-empty
"$a" = "$b" strings equal
"$a" != "$b" strings differ
"$x" -eq "$y" numbers equal
-lt -le -gt -ge numeric less/greater (or equal)
-e path path exists
-f path exists and is a regular file
-d path exists and is a directory
-r / -w / -x path readable / writable / executable

Loops

for

# Iterate over a list
for svc in nginx sshd chronyd; do
    systemctl is-active "$svc"
done

# Iterate over files (glob)
for f in /var/log/*.log; do
    echo "Found: $f"
done

# C-style numeric loop
for ((i = 1; i <= 5; i++)); do
    echo "iteration $i"
done

while and until

# while: run as long as the condition is true
count=0
while [ "$count" -lt 3 ]; do
    echo "count=$count"
    count=$((count + 1))
done

# until: run until the condition becomes true
until ping -c1 db01 >/dev/null 2>&1; do
    echo "waiting for db01..."
    sleep 2
done

case

case is cleaner than a long if/elif chain when matching one value:

case "$1" in
    start)   echo "starting" ;;
    stop)    echo "stopping" ;;
    restart) echo "restarting" ;;
    *)       echo "usage: $0 {start|stop|restart}"; exit 1 ;;
esac

Functions

#!/usr/bin/env bash

# Define a function; arguments arrive as $1, $2, ... inside it
log() {
    echo "[$(date +%T)] $*"
}

greet() {
    local name="$1"        # 'local' keeps the variable scoped to the function
    echo "Hello, ${name}"
}

log "Starting"
greet "world"

Use local

Always declare function variables with local so they do not leak into the global scope and clobber other variables.

Reading input

#!/usr/bin/env bash
read -r -p "Enter a hostname: " host
echo "You typed: $host"

# Read a password without echoing it
read -r -s -p "Password: " pw
echo

The -r flag stops read from mangling backslashes — almost always what you want.

Arithmetic

Use $(( )) for integer math:

a=7
b=3
echo "$(( a + b ))"     # 10
echo "$(( a * b ))"     # 21
echo "$(( a % b ))"     # 1  (remainder)

i=0
i=$(( i + 1 ))          # increment

Bash does integer arithmetic only. For floating point, pipe through bc or use awk:

echo "scale=2; 22 / 7" | bc      # 3.14

Arrays (brief)

# Indexed array
hosts=(web01 web02 db01)
echo "${hosts[0]}"        # web01
echo "${hosts[@]}"        # all elements
echo "${#hosts[@]}"       # element count: 3

for h in "${hosts[@]}"; do
    echo "$h"
done

The safety preamble: set -euo pipefail

Put this near the top of serious scripts:

set -euo pipefail
Option Effect
-e Exit immediately if any command exits non-zero (fail fast)
-u Treat use of an unset variable as an error (catches typos)
-o pipefail A pipeline fails if any stage fails, not just the last one
# Without pipefail, this "succeeds" because tail exits 0,
# hiding that 'somecmd' failed:
somecmd | tail -1

# With 'set -o pipefail', the pipeline's exit code reflects somecmd's failure.

set -e has sharp edges

With -e, a command you expect to fail (e.g. grep finding nothing) will abort the script. Guard such commands: grep foo file || true.

A complete worked example

A disk-health-check script that warns when any mounted filesystem crosses a usage threshold, logs its run, and exits non-zero on a problem (so cron or a monitor can detect it):

#!/usr/bin/env bash
#
# disk-check.sh — warn when filesystem usage exceeds a threshold.
# Usage: ./disk-check.sh [threshold_percent]   (default 90)

set -euo pipefail

THRESHOLD="${1:-90}"
LOGFILE="/var/log/disk-check.log"
problem=0

log() {
    echo "[$(date '+%F %T')] $*" | tee -a "$LOGFILE"
}

log "Starting disk check (threshold=${THRESHOLD}%)"

# df -P gives stable, parseable output; skip the header with tail -n +2.
while read -r filesystem size used avail pcent mount; do
    usage="${pcent%\%}"          # strip the trailing % sign
    if [[ "$usage" =~ ^[0-9]+$ ]] && (( usage >= THRESHOLD )); then
        log "WARNING: ${mount} is at ${pcent} (device ${filesystem})"
        problem=1
    fi
done < <(df -P -x tmpfs -x devtmpfs | tail -n +2)

if (( problem == 0 )); then
    log "OK: all filesystems below ${THRESHOLD}%"
else
    log "ALERT: one or more filesystems are full"
fi

exit "$problem"

Sample run:

$ sudo ./disk-check.sh 80
[2026-06-07 11:42:03] Starting disk check (threshold=80%)
[2026-06-07 11:42:03] WARNING: /var is at 87% (device /dev/mapper/almalinux-var)
[2026-06-07 11:42:03] ALERT: one or more filesystems are full
$ echo $?
1

The < <(...) is process substitution: it feeds the output of df into the while loop without a subshell, so the problem variable set inside the loop survives. (A plain df | while ... pipe runs the loop in a subshell, and your changes would be lost.)

This pairs naturally with scheduling — see Cron & systemd timers to run it every hour.

Debugging

bash -x

Run a script in trace mode to see each command after expansion:

bash -x ./disk-check.sh 80
+ THRESHOLD=80
+ LOGFILE=/var/log/disk-check.log
+ problem=0
+ log 'Starting disk check (threshold=80%)'
...

You can also turn tracing on for just a section inside the script with set -xset +x.

shellcheck

shellcheck is a static analyser that catches quoting bugs, unsafe constructs, and typos before they bite:

# AlmaLinux / RHEL (EPEL)
sudo dnf install -y epel-release
sudo dnf install -y ShellCheck

# Debian / Ubuntu
sudo apt install -y shellcheck

# Run it
shellcheck disk-check.sh
In disk-check.sh line 23:
    if [ $usage -ge $THRESHOLD ]; then
         ^----^ SC2086: Double quote to prevent globbing and word splitting.

Make it a habit

Run shellcheck on every script before committing it. It is the single most effective way to write robust Bash.

Verify your work

# 1. The script is executable and has a valid shebang
head -1 disk-check.sh        # -> #!/usr/bin/env bash
ls -l disk-check.sh          # -> -rwxr-xr-x ...

# 2. It runs and reports a sane exit code
./disk-check.sh 99 ; echo "exit=$?"   # likely OK, exit=0

# 3. shellcheck reports no issues
shellcheck disk-check.sh && echo "clean"

# 4. Trace shows expected expansions
bash -x ./disk-check.sh 99 2>&1 | head

Summary

  • Start scripts with #!/usr/bin/env bash, chmod +x them, and run with ./script.sh.
  • Quote your variables ("$var"), use ${var} for clarity, and capture command output with $(...).
  • Test with [[ ]] in Bash; remember $? is the last exit code and 0 means success.
  • Use for/while/until/case for control flow and local variables inside functions.
  • Add set -euo pipefail for fail-fast safety, and verify with bash -x and shellcheck.

Next: automate your script with Cron & systemd timers.

Test yourself