Bash Traps and the Art of Not Leaving a Mess
I once ran a deploy script that got killed halfway through an rsync. The remote server had half the files from the new version and half from the old one. The site was broken for twenty minutes while I figured out what happened.
That is when I started using traps.
The Problem
Bash scripts are fragile by default. If a command fails, the script keeps going. If you hit Ctrl-C, whatever state you left things in is where they stay. If your SSH connection drops mid-script, same thing.
Most people know about set -e which exits on the first error. But that only covers one failure mode. What about cleanup?
Enter trap
The trap builtin lets you run a command when the script receives a signal or exits:
#!/bin/bash
set -euo pipefail
TMPDIR=$(mktemp -d)
cleanup() {
echo "Cleaning up $TMPDIR..."
rm -rf "$TMPDIR"
}
trap cleanup EXIT
# Your actual work here
cp important-files "$TMPDIR/"
process "$TMPDIR/important-files"
mv "$TMPDIR/important-files" /final/destination/
The EXIT trap fires no matter how the script ends. Normal exit, error, Ctrl-C, even kill. The temp directory always gets cleaned up.
A Real Deploy Script
Here is the pattern I use for deployments:
#!/bin/bash
set -euo pipefail
SERVER="myserver"
REMOTE_DIR="/opt/app"
BACKUP_DIR="/opt/app-backup-$(date +%s)"
ROLLED_BACK=false
rollback() {
if [ "$ROLLED_BACK" = true ]; then return; fi
ROLLED_BACK=true
echo "Deploy failed. Rolling back..."
ssh "$SERVER" "rm -rf $REMOTE_DIR && mv $BACKUP_DIR $REMOTE_DIR"
echo "Rollback complete."
}
trap rollback ERR
# Backup current version
ssh "$SERVER" "cp -r $REMOTE_DIR $BACKUP_DIR"
# Deploy
rsync -a --delete ./dist/ "$SERVER:$REMOTE_DIR/"
ssh "$SERVER" "cd $REMOTE_DIR && docker compose up -d"
# Success - remove backup
ssh "$SERVER" "rm -rf $BACKUP_DIR"
echo "Deploy complete."
If anything after the backup fails, the old version gets restored automatically. No manual intervention, no half-broken state.
set -euo pipefail
This trio should be at the top of every script you write:
set -eexits on any command failureset -utreats unset variables as errors instead of empty stringsset -o pipefailmakes pipes fail if any command in the chain fails, not just the last one
Without pipefail, this silently succeeds even when curl fails:
curl https://example.com/data | jq .result
echo "Done"
jq gets an empty input, outputs null, and the script continues. With pipefail, the whole pipe fails and set -e kills the script.
Trapping Multiple Signals
You can trap specific signals separately:
trap "echo Interrupted; cleanup; exit 130" INT
trap "echo Terminated; cleanup; exit 143" TERM
trap cleanup EXIT
But usually just trapping EXIT is enough. It covers everything.
The key insight is that bash does not protect you from yourself. You have to opt into safety. But once you do, scripts become surprisingly reliable.