Write Shell Scripts That Are Safe to Run Twice
Make setup and deploy scripts idempotent: guard file creation, detect prior success, and structure mutations so reruns converge instead of duplicate.
Task
Harden a setup script so rerunning it after partial failure converges to the correct state without duplicating side effects.
When to use this
A bootstrap script died halfway on a new machine. Rerunning it appended a second PATH export, created backup copies of backups, and crashed on an existing user.
Prerequisites
- Bash 4+ or POSIX sh
- A multi-step setup or deploy script to harden
- A scratch environment to test both fresh and partial states
Steps
1. Switch on strict mode before anything else.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# -e: exit on first error
# -u: unset variable is an error
# -o pipefail: pipeline fails if any stage fails
# IFS: word-splitting surprises die here
Expected output: The script now stops at the first real failure instead of continuing in a corrupted half-state.
2. Guard every creation with existence or atomic replace.
# idempotent: add line only if absent
line='export PATH="$HOME/bin:$PATH"'
grep -qxF "$line" ~/.bashrc || echo "$line" >> ~/.bashrc
# idempotent: directory creation
mkdir -p ~/.local/share/app
# idempotent: config from template (never clobber edits)
[ -f ~/.apprc ] || cp skeleton/apprc ~/.apprc
# atomic file write: temp + rename, rerun-safe
render_template() {
local tmp
tmp=$(mktemp)
cat > "$tmp" <<< "$1"
mv "$tmp" "$2"
}
Expected output: Reruns no longer duplicate PATH lines, and template files never overwrite user edits made after the first run.
3. Detect prior success with marker files instead of guessing.
MARKER_DIR="$HOME/.cache/app-setup"
mkdir -p "$MARKER_DIR"
step() { # step <name> <command...>
local name=$1; shift
if [ -f "$MARKER_DIR/$name.done" ]; then
echo "skip: $name (already done)"
return 0
fi
"$@"
touch "$MARKER_DIR/$name.done"
}
step create_user useradd -m app
step install_deps apt-get install -y jq
step render_conf render_template "$CONF" /etc/app.conf
Expected output: Each step records completion; a rerun after a mid-script crash resumes at the first unfinished step with clear skip messages.
4. Make state mutations converge instead of append.
# NON-idempotent: appends on every run
echo "server = $HOST" >> /etc/app.conf
# Idempotent: regenerate the whole file from declared state
cat > /etc/app.conf <<EOF
server = $HOST
port = $PORT
log_level = info
EOF
# Idempotent: user exists in desired state
id -u app >/dev/null 2>&1 || useradd -m app
usermod -aG docker app # adds only if missing
Expected output: Configuration files converge to a declared final state no matter how many times the script runs, rather than growing with each execution.
5. Prove idempotency by running twice and diffing the world.
bash setup.sh > run1.log 2>&1; find /etc/app* "$HOME" -newer marker0 -type f 2>/dev/null | sort > state1.txt; touch marker0; bash setup.sh > run2.log 2>&1; find /etc/app* "$HOME" -newer marker0 -type f 2>/dev/null | sort > state2.txt; diff state1.txt state2.txt && grep -c "^skip:" run2.logExpected output: The second run changes no files (empty diff) and prints skip counts for every step: idempotency demonstrated, not assumed.
Troubleshooting
Problem
A step is only partially idempotent
Solution
Split it: separate the destructive from the convergent part, make the destructive part conditional on a marker, and let the convergent part always run; database migrations need the same treatment via version tables.
Problem
Markers make stale steps skip after a code change
Solution
Include a content hash in the marker name (step.done vs step.<sha>.done) so changing the step definition invalidates its marker automatically.
Problem
Strict mode breaks an existing script with unquoted vars
Solution
Fix the script, not the mode: set -u is finding real bugs; quote expansions, provide defaults with ${VAR:-}, and treat the failures it surfaces as the review you skipped.
Limitations
- Idempotency is per-effect: external systems with side effects (emails, charges) need their own dedup keys beyond script structure.
- Marker files assume a stable host; containerized one-shot runs should derive state from declared inputs instead.
A script you fear to rerun is a script you will fear to run
Half-finished setup scripts are inevitable; duplicating side effects on rerun is optional. Strict mode, guarded creation, completion markers, and converge-not-append mutations turn a fragile bootstrap into one you can rerun with confidence during an incident.
# The rerun test: second run must be a no-op
bash setup.sh > run1.log
find /etc/app* "$HOME" -newer marker -type f | sort > s1.txt
touch marker
bash setup.sh > run2.log
find /etc/app* "$HOME" -newer marker -type f | sort > s2.txt
diff s1.txt s2.txt # empty = nothing changed
run2.log:
# skip: create_user (already done)
# skip: install_deps (already done)
# render_conf: regenerated (converges, no growth)
Convergence beats append
Appending lines to a config on every run means the file encodes its own history instead of its desired state. Regenerating from a heredoc or template makes the file a function of declared inputs: run it once or fifty times, the result is identical, and diffs in code review are meaningful.
Markers encode progress, hashes encode validity
A plain done marker skips a step forever, even after you fix the bug that made it wrong. Hashing the step definition into the marker name means editing the step invalidates its own marker, and the rerun does the work again exactly when it should.