Skip to main content

Designing Idempotent Bootstrap Scripts

A technique for designing environment-setup scripts that are safe to run any number of times (idempotent).

What is idempotency?​

An idempotent operation produces the same result no matter how many times it runs. For a setup script, idempotency means "skip what is already installed, and add only what is missing."

1st run: install packages
2nd run: do nothing and succeed (already installed)

This makes it safe to re-run in cases such as:

  • Retrying after a partial failure
  • Applying drift after updating definitions
  • One-shot restoration on a new machine

The basic pattern: check-then-install​

Idempotency boils down to "check state first, then act only when needed." Here is an example of a function that installs apt packages idempotently.

install_apt() {
local -a pkgs=("$@")
local -a missing=()
local p
for p in "${pkgs[@]}"; do
dpkg-query -W -f='${Status}' "$p" 2>/dev/null \
| grep -q 'install ok installed' || missing+=("$p")
done
if [ ${#missing[@]} -eq 0 ]; then
echo "すべて導入済み"
return 0
fi
sudo apt-get update
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}"
}

The point is to re-evaluate what is already installed on every run and target only the missing items.

Use command -v to check for the presence of a command.

have() { command -v "$1" >/dev/null 2>&1; }

if have starship; then
echo "導入済み: $(starship --version | head -1)"
else
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"
fi

Boilerplate for robustness​

Put these standard settings at the top of the script to improve reliability.

set -euo pipefail
OptionEffect
-eExit immediately on command failure
-uError on unset variable references
-o pipefailDetect failures mid-pipeline

Adding small helpers for consistent logging improves readability.

log() { printf '\n==> %s\n' "$*"; }
warn() { printf 'WARN: %s\n' "$*" >&2; }

dry-run (confirmation mode)​

A dry-run mode that only prints what would happen — without changing anything — is an important safety mechanism. Wrap execution and display in a single function.

run() {
if [ "$DRY_RUN" = 1 ]; then
printf '[dry-run] %s\n' "$*"
else
"$@"
fi
}

For remote installer scripts, showing only the URL in dry-run is safer.

remote_sh() {
local url="$1"; shift
if [ "$DRY_RUN" = 1 ]; then
echo "[dry-run] curl -fsSL $url | sh -s -- $*"
return 0
fi
curl -fsSL "$url" | sh -s -- "$@"
}
caution

curl | sh is convenient, but you cannot inspect the content before it runs. Restrict it to trusted distributors and verify the content or its hash where possible.

Selecting behavior with flags​

Use flags to separate sudo-dependent steps and optional features (such as a desktop environment).

DRY_RUN=0; SKIP_SUDO=0; WITH_DESKTOP=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
--no-sudo) SKIP_SUDO=1 ;;
--with-desktop) WITH_DESKTOP=1 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done

Because whether sudo is available depends on the environment, absorb it with a need_root helper.

need_root() {
if [ "$(id -u)" -eq 0 ]; then "$@"; else sudo "$@"; fi
}

For CI use, an environment variable (e.g. ASSUME_YES=1) can suppress interactive confirmation.

The limits of idempotency, and when to switch tools​

Shell scripts are lightweight with a low learning curve, but the more complex the state, the harder idempotency is to guarantee. A rough guideline:

SituationRecommendation
Single machine, mostly tool installationShell script (the setup on this page)
Multiple machines, declarative stateAnsible or similar configuration management
Fully reproducible environmentdevcontainer / Nix

A pragmatic path is to start with an idempotent shell script, then move to Ansible or Nix as complexity grows.

Typical structure of an idempotent bootstrap​

Combining the patterns above, a typical idempotent bootstrap script looks like this:

  • install_apt for idempotent package installation
  • have + remote_sh for tool installation (nvm / starship / uv / various CLIs)
  • --dry-run / --no-sudo / --with-desktop flags
  • set -euo pipefail and log / warn helpers
  • Finally, chezmoi apply to apply dotfiles

See Development Environment as Code for the overall structure and Managing Dotfiles with chezmoi for the dotfiles step.