Build a real disk-cleanup script step by step. Learn variables, conditionals, loops, error handling, and the safety preamble that prevents foot-guns.
By the end of this post you'll have written a disk-cleanup script that finds and reports old files, with proper error handling and a --dry-run mode. You'll know enough bash to write small operational tools that don't blow up at 2 AM. About 25 minutes.
You'll need a Linux or macOS terminal with bash (most do; check with bash --version).
Bash is the lingua franca of operations. Every Linux server has it. Every CI runner has it. Every container has it. For small "glue between commands" tasks, nothing beats it for speed and ubiquity. For anything beyond ~150 lines, reach for Python — but the small stuff is bash.
The skill you want is enough fluency to write 30-line scripts that work safely, not deep mastery. Five concepts cover most operational scripts.
Every non-trivial bash script starts with this:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
What each does:
#!/usr/bin/env bash — shebang line. Says "use bash, find it via PATH." Works on Mac and various Linux distros.set -e — exit on any error. Without this, scripts blunder past failures.set -u — error on undefined variables. Catches typos like $USRENAME instead of $USERNAME.set -o pipefail — pipe failures count as errors. Without this, cmd1 | cmd2 succeeds whenever cmd2 succeeds, even if cmd1 failed.These four lines prevent a big chunk of the "but it worked on my laptop" bash bugs. Use them.
Variables are simple: NAME=value (no spaces around =). Reference with $NAME. Always quote when you reference: "$NAME", not $NAME.
Why the quotes matter:
NAME="Alice Smith"
echo $NAME # prints: Alice Smith (works by accident)
ls $NAME # tries to ls TWO files: Alice and Smith
ls "$NAME" # tries to ls one file: "Alice Smith"
Always quoting is the safe default. ShellCheck (a linter we'll use at the end) catches missing quotes.
Try this:
NAME="${USER:-stranger}"
echo "Hello, $NAME"
The ${VAR:-default} syntax: use $VAR if set, otherwise default. Useful for optional config.
Create cleanup.sh:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Default values
DAYS="${DAYS:-30}"
DIR="${DIR:-/tmp}"
DRY_RUN=0
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--days) DAYS="$2"; shift 2 ;;
--dir) DIR="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--help)
echo "Usage: $0 [--days N] [--dir PATH] [--dry-run]"
echo " Find files older than N days in DIR (default: 30 days, /tmp)"
exit 0
;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
if [[ ! -d "$DIR" ]]; then
echo "Error: $DIR is not a directory" >&2
exit 1
fi
echo "Scanning $DIR for files older than $DAYS days..."
count=0
total_size=0
while IFS= read -r -d '' file; do
size=$(stat -f %z "$file" 2>/dev/null || stat -c %s "$file") # macOS or Linux
total_size=$((total_size + size))
count=$((count + 1))
if [[ $DRY_RUN -eq 1 ]]; then
echo "[dry-run] would delete: $file"
else
rm "$file"
echo "deleted: $file"
fi
done < <(find "$DIR" -type f -mtime +"$DAYS" -print0)
echo
echo "Files: $count"
echo "Total size: $((total_size / 1024 / 1024)) MB"
[[ $DRY_RUN -eq 1 ]] && echo "(dry run — nothing actually deleted)"
Make it executable:
chmod +x cleanup.sh
Run it in dry-run mode first (always — for any script that deletes things):
./cleanup.sh --dry-run --days 7 --dir /tmp
You should see a list of files older than 7 days in /tmp that it would delete, with a count and total size at the end. No actual deletion happens.
If that looks right, drop --dry-run:
./cleanup.sh --days 7 --dir /tmp
That's a real, useful operational script. Walk through what each piece does:
case "$1" in ... esac block handles --days, --dir, --dry-run, --help. Each option consumes 1 or 2 positional args (shift advances).if [[ ! -d "$DIR" ]] check catches typos in the directory path before we proceed.find ... -print0 | while read -d '' pattern — the standard way to safely loop over files with arbitrary names (including spaces and newlines). Read every file path one at a time.stat — macOS and Linux have different syntax. The fallback handles both.Bigger scripts benefit from functions and cleanup hooks. Quick example:
#!/usr/bin/env bash
set -euo pipefail
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
log() {
echo "[$(date '+%H:%M:%S')] $*" >&2
}
main() {
log "Working in $TMPDIR"
# ... do stuff with $TMPDIR ...
log "Done"
}
main "$@"
trap '<command>' EXIT runs the command whether the script succeeds, fails, or is interrupted. Perfect for cleaning up temp files.
log() is a tiny convention: write status to stderr (>&2) so it doesn't pollute stdout, which scripts in pipelines might need to consume.
main "$@" at the bottom runs the actual logic, passing through the original arguments.
ShellCheck catches a huge category of bash bugs automatically. Install:
# Mac
brew install shellcheck
# Ubuntu/Debian
sudo apt install shellcheck
Run on your script:
shellcheck cleanup.sh
You should see zero or just-a-few warnings. Fix anything it flags. Run shellcheck on every script you write — it catches mistakes faster than running them in production does.
Forgetting quotes. Probably the #1 bash bug. Variables without quotes silently break on filenames with spaces, empty values, or weird characters. Quote everything.
Using [ ... ] instead of [[ ... ]]. The single-bracket form is older and less powerful. Use [[ ... ]] — supports regex, doesn't word-split, clearer syntax. Bash-only, but you're using bash.
Skipping the safety preamble. Without set -euo pipefail, scripts continue past failures. You won't notice until something corrupts data.
Parsing complex output with cut and awk. Once a script is parsing JSON or doing nontrivial logic, you've outgrown bash. Move to Python. The migration is faster than the next bug.
Editing scripts on the fly without testing. Always have a dry-run mode. Always test on a sandbox before running in production. Every "rm -rf $UNSET_VAR" disaster started with a confident edit.
You've got the basics. The next levels:
Bash scripting is a "glue" skill — the most useful 50 lines you'll ever write are usually bash. Get the safety preamble in muscle memory, quote your variables, lint with ShellCheck, and you'll avoid the worst classes of bugs.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A working mental model for AWS VPCs — what each piece does, how they connect, and why "VPC" is the wrong mental model if you came from physical networks.
A working retrieval-augmented generation app you can run today. Markdown ingestion, embeddings, semantic search, and an LLM answer — start to finish in one afternoon.
Explore more articles in this category
Generate an SSH key, set up passwordless login, and configure aliases for the servers you use daily — all without copy-pasting yet another long command.
A clear walkthrough of Linux file permissions. Read the funny rwx- letters, change them safely with chmod, fix "permission denied" errors with confidence.
We started using eBPF tooling for ad-hoc production debugging six months ago. Three real incidents where it cut investigation time from hours to minutes.
IFS=$'\n\t' — only split words on newlines and tabs, not spaces. Makes filenames-with-spaces work correctly.stat2>/dev/null || stat -c %s