Bash scripts automate the repetitive parts of running servers: backups, deploys, health checks, cleanups. This tutorial covers the fundamentals plus the habits that make scripts reliable — the difference between a script that quietly does the wrong thing and one that fails loudly and safely.
Your first script
#!/usr/bin/env bash
echo "Hello from $(hostname) at $(date)"
Save as hello.sh, make it executable and run it:
chmod +x hello.sh
./hello.sh
The first line, the shebang, tells the system which interpreter to use. Use bash explicitly — /bin/sh on Debian/Ubuntu is dash, which lacks many bash features.
Strict mode: start every script with this
#!/usr/bin/env bash
set -euo pipefail
-e— exit when a command fails.-u— treat unset variables as errors (catches typos like$DIRR).-o pipefail— a pipeline fails if any command in it fails, not just the last one.
Without these, a failed cd followed by rm -rf * can delete the wrong directory. See bash error handling.
Variables and quoting
name="web1" # no spaces around =
backup_dir="/var/backups"
echo "Server: $name"
echo "Path: ${backup_dir}/db"
today=$(date +%F) # command substitution
Always double-quote variables: "$file", not $file. Unquoted variables split on spaces and expand wildcards — a file named my report.txt becomes two arguments. Single quotes prevent expansion entirely: '$HOME' is literally $HOME.
More in bash variables.
Arguments
#!/usr/bin/env bash
set -euo pipefail
domain="${1:?Usage: $0 <domain>}" # required first argument
port="${2:-443}" # optional, default 443
echo "Checking $domain on port $port ($# arguments)"
$1, $2… are positional arguments, $# their count, "$@" all of them. See script arguments.
Conditions
if [[ -f /etc/nginx/nginx.conf ]]; then
echo "Nginx is installed"
elif [[ -d /etc/apache2 ]]; then
echo "Apache is installed"
else
echo "No web server found"
fi
Common tests:
| Test | True if |
|---|---|
-f path |
Regular file exists |
-d path |
Directory exists |
-x path |
Executable |
-z "$s" / -n "$s" |
String empty / not empty |
"$a" == "$b" |
Strings equal |
$n -gt 5 |
Number greater (-lt, -eq, -ge, -le, -ne) |
Test a command’s success directly:
if systemctl is-active --quiet nginx; then echo "running"; fi
if ! ping -c1 -W2 10.0.0.5 > /dev/null; then echo "DB host unreachable"; fi
More in bash if-else.
Loops
for site in example.com shop.example.com; do
curl -fsS -o /dev/null "https://$site" || echo "$site is down"
done
for file in /var/log/nginx/*.log; do
echo "$file: $(wc -l < "$file") lines"
done
while read -r user; do
echo "Creating $user"
done < users.txt
See bash for loops.
Functions
log() {
echo "[$(date '+%F %T')] $*" >&2
}
check_disk() {
local mount="$1" limit="$2"
local used
used=$(df --output=pcent "$mount" | tail -1 | tr -dc '0-9')
(( used < limit ))
}
if ! check_disk / 85; then log "Root disk above 85%"; fi
Use local for variables inside functions. A function’s “return value” is its exit status; output text with echo and capture it with $(...). See bash functions.
Exit codes
Every command returns 0 for success, non-zero for failure. $? holds the last one. End your scripts with meaningful codes (exit 1 on error) so cron, CI and monitoring can detect failures.
Cleanup with trap
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
The trap runs whenever the script exits — normally or because of an error.
A real example: database backup
#!/usr/bin/env bash
set -euo pipefail
DEST=/var/backups/mysql
KEEP_DAYS=14
STAMP=$(date +%F_%H%M)
log() { echo "[$(date '+%F %T')] $*"; }
mkdir -p "$DEST"
for db in $(mysql -N -e "SHOW DATABASES" | grep -Ev '^(information_schema|performance_schema|mysql|sys)$'); do
log "Dumping $db"
mysqldump --single-transaction --routines "$db" | gzip > "$DEST/$db-$STAMP.sql.gz"
done
log "Removing backups older than $KEEP_DAYS days"
find "$DEST" -name '*.sql.gz' -mtime +"$KEEP_DAYS" -delete
log "Done"
Schedule it with cron. Without pipefail, a failing mysqldump would still produce a “successful” (empty) gzip file.
Debugging
bash -n script.sh # syntax check only
bash -x script.sh # print each command as it runs
Install ShellCheck (sudo apt install shellcheck) and run shellcheck script.sh — it catches quoting bugs and common mistakes before they bite.
When to switch to another language
Bash is excellent for gluing commands together. When a script grows beyond a few hundred lines, needs data structures, JSON handling or complex error handling, Python is usually a better fit.
Next: environment variables, and sed and awk for text processing inside scripts.