Bash scripting basics: write safe scripts that do not surprise you
Bash automates server chores, but careless scripts delete the wrong thing. Build a tested backup script and learn the safe habits.

Bash is the language of server chores: backups, deployments, log cleanup. It is easy to start and easy to get dangerously wrong, because a small typo can delete the wrong folder. This article teaches the basics by building one useful script, then explains the safe habits. The script and outputs below were run on Linux with Bash 5 before publishing.
The script
Save as backup.sh and run chmod +x backup.sh. It creates a dated archive of a folder and keeps only the newest few:
#!/usr/bin/env bash
# backup.sh SOURCE_DIR DEST_DIR [KEEP] - make a dated .tar.gz backup and keep only the newest KEEP files
set -euo pipefail
usage() {
echo "Usage: $0 SOURCE_DIR DEST_DIR [KEEP]" >&2
exit 2
}
[[ $# -ge 2 ]] || usage
source_dir=$1
dest_dir=$2
keep=${3:-3}
[[ -d "$source_dir" ]] || { echo "Error: $source_dir is not a directory" >&2; exit 1; }
[[ "$keep" =~ ^[0-9]+$ ]] || { echo "Error: KEEP must be a number" >&2; exit 1; }
mkdir -p "$dest_dir"
tmp=$(mktemp "$dest_dir/.partial.XXXXXX")
trap 'rm -f "$tmp"' EXIT # clean up if anything fails
name="$(basename "$source_dir")-$(date +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$tmp" -C "$(dirname "$source_dir")" "$(basename "$source_dir")"
mv "$tmp" "$dest_dir/$name"
echo "Created $dest_dir/$name ($(du -h "$dest_dir/$name" | cut -f1))"
# delete old backups, newest first, keep $keep
count=0
for file in $(ls -1t "$dest_dir"/"$(basename "$source_dir")"-*.tar.gz); do
count=$((count + 1))
if (( count > keep )); then
rm -- "$file"
echo "Removed old backup $(basename "$file")"
fi
done
Run it
$ ./backup.sh site backups 2
Created backups/site-20261028-030946.tar.gz (4.0K)
$ ./backup.sh site backups 2
Created backups/site-20261028-030948.tar.gz (4.0K)
$ ./backup.sh site backups 2
Created backups/site-20261028-030949.tar.gz (4.0K)
Removed old backup site-20261028-030946.tar.gz
$ ls backups
site-20261028-030948.tar.gz site-20261028-030949.tar.gz
$ ./backup.sh
Usage: ./backup.sh SOURCE_DIR DEST_DIR [KEEP] (exit code 2)
$ ./backup.sh nope backups
Error: nope is not a directory (exit code 1)
$ ./backup.sh site backups x
Error: KEEP must be a number (exit code 1)
(The backup is of a small test folder, which is why it is only a few kilobytes.)
The basics, line by line
The shebang #!/usr/bin/env bash tells the system which interpreter runs the file. Run the script as ./backup.sh after making it executable.
Variables and arguments. $1, $2, $3 are the arguments; $# is how many. keep=${3:-3} means "use the third argument, or 3 if it is missing". Assign with no spaces around =: name=value.
Conditions. [[ -d "$dir" ]] tests that a directory exists. Other useful tests: -f (file), -z (empty string), -e (exists). [[ ... ]] is the Bash form. (( count > keep )) is for number comparisons.
Command substitution. $(date +%Y%m%d-%H%M%S) runs a command and puts its output in place. That is how the file name gets its timestamp.
Functions. usage() { ...; } groups commands you reuse.
Loops. for file in ...; do ... done repeats for each item.
Exit codes. Every command returns a number: 0 means success, anything else means failure. exit 2 and exit 1 tell callers (cron, CI, other scripts) what happened. echo "$?" shows the last exit code. Send error messages to stderr with >&2.
The safety line: set -euo pipefail
This one line prevents many disasters:
-e: stop the script when a command fails, instead of carrying on.-u: treat an unset variable as an error. Without it,rm -rf "$DIR/"with an emptyDIRbecomesrm -rf /.-o pipefail: a pipeline fails if any part fails, not just the last.
It is not magic and has edge cases, but it is a very good default for new scripts.
Always quote your variables
rm -- "$file" # good: survives spaces in the name
rm $file # bad: "my notes.txt" becomes two arguments
Write "$var" with double quotes almost everywhere. Without quotes, spaces and wildcards in values change what the command does. The -- marks the end of options, so a file named -rf is not read as a flag.
Cleaning up with trap
The script writes to a temporary file first, and trap 'rm -f "$tmp"' EXIT deletes it however the script ends: success, error or Ctrl+C. Only after tar succeeds do we mv the file to its final name. This means a half-written backup never looks like a real one. Write to a temp name, then rename, is a pattern worth copying.
Check your work before running
bash -n script.shchecks syntax without running it.shellcheck script.shis a free linter that catches quoting mistakes and other traps. It is widely recommended; install it from your package manager.- Try with a harmless test folder first. Put an
echoin front of dangerous commands (echo rm -- "$file") to see what would run. bash -x script.shprints each command as it executes, for debugging.
A known weak spot in this script
The cleanup loop reads file names from ls. That is fine here because we create the names ourselves (no spaces or odd characters), but parsing ls is fragile in general. For arbitrary file names use find with -print0 or a glob in a for loop. Knowing where your script is fragile is part of writing safe scripts.
Run it on a schedule
Backups are only useful if they run regularly. With cron, 0 2 * * * /opt/scripts/backup.sh /var/www/site /backups 7 runs it at 02:00 daily. Use absolute paths in cron, because its environment is minimal. systemd timers are a modern alternative. And remember the rule from our backup article: a backup you have never restored is a hope, not a backup. Keep a copy off the server too.
When not to use Bash
Bash is great for gluing commands together. When a script needs data structures, JSON, HTTP calls or lots of logic, a language like Python is usually clearer and safer. A rule of thumb: if your Bash script grows beyond about 100 lines or needs arrays of arrays, consider rewriting it.
Practice ideas
- Add a
--dry-runoption that only prints what would be deleted. - Write a script that warns when disk usage on
/is above 80%. - Make a script that checks whether a URL returns 200 and exits with 1 if not.
- Rotate log files by size.
Takeaway: start every script with set -euo pipefail, quote variables, validate arguments, clean up with trap, test on harmless data, and run shellcheck. Those habits turn Bash from risky to reliable.
Linux & DevOps series, Part 3 (everyday operations): 3 of 5. Previous: Linux troubleshooting: the commands to run when a server misbehaves.


