How to Write a Bash Script (Variables, Ifs, Loops and the Mistakes Everyone Makes)

Any sequence of commands you've typed twice should be a script. A bash script is just those commands in a file, with a few extras — variables, conditions, loops — that let it handle more than one situation. This covers everything you need for the scripts a home server runs: backups, cleanups, checks and alerts.
Quick reference
| Thing | Syntax |
|---|---|
| First line | #!/bin/bash |
| Stop on first error | set -e |
| Variable | name="value" then "$name" |
| Command output into a variable | today=$(date +%F) |
| If | if [ -f "$file" ]; then ... fi |
| Loop over files | for f in /var/log/*.log; do ... done |
| Arguments | $1, $2, all: "$@" |
| Run it | chmod +x script.sh then ./script.sh |
1. The smallest script
nano hello.sh
#!/bin/bash
echo "Hello from $(hostname) on $(date)"
chmod +x hello.sh
./hello.sh
The first line tells Linux which program runs the file. $(...) runs a command and drops its output in place.
2. Variables
backup_dir="/mnt/backup"
today=$(date +%F)
echo "Backing up to $backup_dir/$today"
No spaces around =. Always put "$variable" in double quotes — otherwise a value with a space in it becomes two arguments and things break in confusing ways.
3. Stop when something fails
#!/bin/bash
set -euo pipefail
Put this second line in every script. -e exits on the first failing command (so a failed cd doesn't lead to rm -r in the wrong folder), -u errors on undefined variables (a typo in a name), -o pipefail makes a pipeline fail if any part fails.
4. If statements
if [ -f "/var/run/reboot-required" ]; then
echo "Reboot needed"
fi
if [ "$used" -gt 85 ]; then
echo "Disk is ${used}% full" | mail -s "Disk warning" you@example.com
else
echo "Disk fine"
fi
Common tests: -f file exists, -d folder exists, -z "$s" string is empty, "$a" = "$b" strings equal, "$n" -gt 5 number greater than. Spaces inside the brackets are required.
Test a command instead: if ping -c1 -W1 192.168.1.1 >/dev/null; then echo "router up"; fi.
5. Loops
for f in /home/john/photos/*.jpg; do
echo "Processing $f"
done
for host in 192.168.1.1 192.168.1.50 192.168.1.60; do
ping -c1 -W1 "$host" >/dev/null && echo "$host up" || echo "$host DOWN"
done
while read -r line; do
echo "Line: $line"
done < list.txt
6. Arguments
#!/bin/bash
set -euo pipefail
target="${1:-/mnt/backup}" # first argument, or a default
echo "Target is $target"
./backup.sh /mnt/usb uses the argument; ./backup.sh uses the default. $# is the number of arguments; "$@" is all of them.
7. A real one
#!/bin/bash
# Warn if any Docker container isn't running
set -euo pipefail
down=$(docker ps -a --filter "status=exited" --format "{{.Names}}")
if [ -n "$down" ]; then
echo -e "Stopped containers on $(hostname):\n$down" | mail -s "Containers down" you@example.com
fi
Save it in /usr/local/bin/, chmod +x, add to cron.
The mistakes everyone makes
- Unquoted variables. rm -r $dir/old with dir empty deletes /old. Quote everything: "$dir/old".
- Windows line endings. A script edited on Windows fails with ^M: bad interpreter. Fix: sed -i 's/\r$//' script.sh.
- Relying on the current folder. Cron runs from /. Use full paths or cd "$(dirname "$0")" at the top.
- sh instead of bash. sh script.sh runs it with a simpler shell and breaks [[ ]] and arrays. Use ./script.sh or bash script.sh.
Checking a script before running it
Install shellcheck (sudo apt install shellcheck) and run shellcheck script.sh. It finds unquoted variables and dozens of other classic bugs with clear explanations.
Troubleshooting
"Permission denied". chmod +x. "command not found" for a variable. Spaces around =. "unary operator expected". An empty, unquoted variable in [ ]. Quote it. Runs by hand, not in cron. Full paths (tutorial 20).
Wrapping up
Shebang, set -euo pipefail, quoted variables, if and for, shellcheck before cron. That's enough to automate everything a home server needs, and enough to read other people's scripts before running them.
