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 -e exits on any command failure
  • set -u treats unset variables as errors instead of empty strings
  • set -o pipefail makes 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.

← all articles wleeaf.dev →