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 (#!):
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:
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=value — no 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 |
Every command returns an exit code: 0 means success, anything else means failure. Check it with $?:
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:
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:
| 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:
+ 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 -x … set +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 +xthem, 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 and0means success. - Use
for/while/until/casefor control flow andlocalvariables inside functions. - Add
set -euo pipefailfor fail-fast safety, and verify withbash -xandshellcheck.
Next: automate your script with Cron & systemd timers.