The best project scripts can run twice without making the second run weird.
That is the simplest test I know for whether a script belongs in a repo.
Development scripts get interrupted. Dependencies fail. Ports stay open. A generated file already exists. A Docker container is half-created. A migration ran before the seed step failed. Someone hits control-C at exactly the wrong time. The next person runs the same command and inherits the mess.
An idempotent script treats that as normal.
It can start from a partially completed state, do the right thing, and stop with a useful message.
check before creating
The first habit is to check for existing state before creating more state.
Bad scripts assume the world is empty. Good scripts ask what is already there.
if [ ! -d "node_modules" ]; then
pnpm install
fi
if ! docker volume inspect app_pgdata >/dev/null 2>&1; then
docker volume create app_pgdata
fi
That is not sophisticated. It prevents stupid failures.
The same pattern applies to generated files, local databases, cache directories, build output, SSL certificates, fixture data, and service containers. If the script can detect the desired state, it should converge on it instead of blindly adding another copy.
names should be stable
Idempotency gets harder when scripts create unnamed things.
A container named postgres is easier to reason about than a random container created every run. A temp directory with a job ID is easier to clean than one named by timestamp only. A generated file with a stable path is easier to replace than a pile of numbered outputs.
Stable names make cleanup possible.
For local dev scripts, I want predictable names:
- container names
- volume names
- network names
- PID files
- log files
- lock files
- cache directories
Predictability is not glamorous. It is how stop.sh, reset.sh, and status.sh can exist.
partial failure needs a path
A script should assume it can fail halfway.
The question is what happens next.
If a database starts but migrations fail, can the next run retry migrations? If a file downloads halfway, does the script verify checksum before trusting it? If a service starts and the health check fails, does the script print the log path? If a generated file is invalid, does the script overwrite it cleanly or keep appending?
The script should leave enough evidence for recovery.
I like scripts that make their phases visible:
checking dependencies
starting database
waiting for health check
running migrations
seeding fixtures
writing env file
done
When a phase fails, the user knows where to look.
destructive actions should be explicit
Idempotent does not mean harmless.
A reset script may delete data. A cleanup script may remove containers. A migration script may change schema. Those actions need clear boundaries.
I prefer separate commands:
init.shcreates or updates the local environmentserve.shstarts itstop.shstops running processesreset.shremoves local state intentionallytest.shverifies the expected path
That split avoids the script that sometimes initializes and sometimes destroys depending on flags nobody remembers.
If a command deletes data, make the name say so. If it targets production, stop and require explicit confirmation or use a different tool entirely. Local convenience scripts should not develop production-grade footguns.
locks prevent duplicate work
Some scripts should refuse to run twice at the same time.
Starting two dev servers on the same port is annoying. Running two seed jobs against the same database can be worse. Running two code generators into the same output directory can create garbage.
A simple lock file can be enough:
LOCKFILE=".tmp/init.lock"
if [ -e "$LOCKFILE" ]; then
echo "init is already running or exited uncleanly: $LOCKFILE"
exit 1
fi
mkdir -p .tmp
trap 'rm -f "$LOCKFILE"' EXIT
touch "$LOCKFILE"
Locking should have a recovery path. If the process died, the user should know how to clear the stale lock safely.
environment checks should fail early
An idempotent script should check prerequisites before it mutates state.
If the script needs pnpm, Docker, psql, ffmpeg, a specific Node version, or a local .env file, say that up front. Do not create half the environment and then fail because the last command is missing.
Early checks also make error messages better:
missing: docker
expected: Docker running locally before init
fix: start Docker Desktop and rerun ./init.sh
That is much better than a stack of command failures from deep inside the script.
Version checks belong here too. A script that assumes Node 26 should not quietly run under Node 20 and leave behind strange build output.
output should be boring
Good script output is a kind of interface.
It should say what changed, what was skipped because it already existed, and what failed. It should avoid dumping pages of noise unless the user asks for verbose mode.
Useful output:
database: already running
migrations: up to date
fixtures: seeded 14 records
server: http://localhost:4321
That tells the user the script converged. It also makes reruns feel safe.
the second run is the test
The best test for a project script is simple:
Run it. Run it again.
The second run should not create duplicate data, fail because a file exists, start another copy of a service, corrupt output, change unrelated files, or require manual cleanup.
Then interrupt it halfway and run it again.
That is where the real bugs appear.
Scripts should be idempotent by default because local tooling is part of the product experience for developers. A repo that can recover from a messy half-run is calmer to work in. A repo that cannot makes every setup failure feel personal.
Good scripts make recovery feel ordinary. That is worth designing before setup breaks again.
Related posts

About Jeremy London
Engineering leader and builder in Denver. I write about AI platforms, agents, security, reliability, homelab infrastructure, and the parts of engineering work that have to survive production.