Rule: Never run package managers, build tools, test runners, or development servers on the host. All commands run inside containers via
docker compose exec <service> <command>.
A new contributor cloned ScriptHammer, ran pnpm install on the host, then ran docker compose up. The container booted, Docker mounted the project directory in, and now the container was looking at a node_modules/ that was installed for the host’s Node version (22.x), not the container’s (also 22.x — but compiled against musl, not glibc).
Symptoms:
sharp (image processing) crashed at boot with “wrong ELF class”bcrypt couldn’t load its native bindingnext dev started but every page that imported either of those crashedThe “fix” attempt: sudo rm -rf node_modules on the host. This failed because Docker had created files inside node_modules/.cache/ as the container’s user (UID 1000 from the .env file, which happened to match the host user, but Docker had set them as root-owned for some intermediate operations). Now sudo was required to clean up Docker-owned files. Now the user was sudoing in their project directory. Now permissions were getting weirder.
Real fix:
docker compose down
docker compose run --rm scripthammer rm -rf node_modules
docker compose up
That’s the Docker-first path: ask the container to clean up the container’s mess. Never ask the host.
The contributor lost 90 minutes diagnosing this. They could have lost 0 minutes by running docker compose exec scripthammer pnpm install instead of pnpm install on the host.
Containers exist to provide a reproducible environment. The moment you run any tool on the host, you’ve broken reproducibility:
site-packages if it’s mounted.cache/ directories grow with files Docker doesn’t know aboutThese divergences are invisible until they break something. And when they break, the error messages are unhelpful because the diagnosis is “your host and your container disagree about reality” — which is hard to express as a useful log line.
Docker-first eliminates the entire class of problems by never letting the host touch the project except via the container.
# NEVER on the host
pnpm install
pnpm add zod
# ALWAYS via Docker
docker compose exec scripthammer pnpm install
docker compose exec scripthammer pnpm add zod
# NEVER on the host
pnpm test
pnpm vitest run
# ALWAYS via Docker
docker compose exec scripthammer pnpm test
# NEVER on the host
pnpm run build
# ALWAYS via Docker
docker compose exec scripthammer pnpm run build
Slash commands enforce this. /commit, /test, /code-review, /ship, and /clean-start all run their commands inside Docker. If you write your own slash command, follow the same pattern.
sudo.
# NEVER
sudo chown -R $USER:$USER node_modules
# ALWAYS
docker compose exec scripthammer rm -rf node_modules
docker compose down && docker compose up
The only commands that legitimately run on the host:
git (Git is a host tool, files are mounted into Docker)docker compose itselfcode, vim, etc.)ls, cd, cat of source files)If you find yourself running a build/test/lint/install command on the host, stop. Use Docker.
docker-compose.yml at repo root with at least one service for the appDockerfile that installs dependencies and copies source.dockerignore that excludes node_modules/, .next/, etc.CLAUDE.md with a “Docker-First” section that prohibits host commands explicitly.env.example committed; .env gitignoreddocker compose execThe 01-bootstrap-a-repo/templates/ directory has the templates for all of these.
There are projects where Docker-first is the wrong call:
CLAUDE.md explicitly says “NOT Docker-first.”For these, document the exception in the project’s CLAUDE.md so Claude doesn’t try to apply the Docker-first rule incorrectly.
secrets-never-committed.md — another mandatory rule01-bootstrap-a-repo/templates/docker-compose.yml.template — the starter template03-slash-commands/catalog/git.md — /commit and /ship, both Docker-first03-slash-commands/catalog/testing.md — testing commands, all Docker-first