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
| Option | Effect |
|---|---|
-e | Exit immediately on command failure |
-u | Error on unset variable references |
-o pipefail | Detect 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 -- "$@"
}
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:
| Situation | Recommendation |
|---|---|
| Single machine, mostly tool installation | Shell script (the setup on this page) |
| Multiple machines, declarative state | Ansible or similar configuration management |
| Fully reproducible environment | devcontainer / 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_aptfor idempotent package installationhave+remote_shfor tool installation (nvm / starship / uv / various CLIs)--dry-run/--no-sudo/--with-desktopflagsset -euo pipefailandlog/warnhelpers- Finally,
chezmoi applyto apply dotfiles
See Development Environment as Code for the overall structure and Managing Dotfiles with chezmoi for the dotfiles step.