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.

  • Bash
  • Linux
  • Automation