Technical
Timothy Yang

Why your setup breaks on a new machine (and how to prove it won't)

Your repo describes maybe half of what your project needs to run. The rest lives in your home directory, your shell profile, and a Docker volume you forgot about — here's how to pull it back into the repo and test it cold.


I'll research current tooling for reproducible dev environments before writing.You clone your own repo onto a new laptop, run the dev server, and it dies before it prints a URL. The code is identical, the lockfile is committed, git status is clean — and nothing works, because the thing that made it work on the old machine was never in the repo.

This is not a new problem. But it got noticeably worse once we started letting agents drive. When you build with Claude Code or Cursor, the agent fixes environment problems the same way you would at 11pm: it installs something globally, sets an env var, symlinks a binary, and moves on. It never writes that down. Six weeks later your project has a dozen invisible dependencies on the specific state of one laptop, and the only artefact of that state is the laptop.

Your machine is a cache with no invalidation

Think about what actually has to be true for npm run dev to work. A Node binary of roughly the right version has to be on PATH. A package manager. Native modules compiled for your CPU architecture and libc. Maybe a Postgres running on 5432 with a database somebody created by hand. Maybe a .env you copied from Slack and never committed. Maybe a psql client installed by Homebrew two years ago for an unrelated job.

Your repo describes maybe half of that. The other half lives in ~, in /opt/homebrew, in your shell profile, and in a Docker volume you forgot about. That half is a cache. Caches are fine until you throw one away, and a new machine is exactly that — a cold cache with no way to know what it's missing.

The failure mode isn't "nothing works." It's worse: most things work, and one thing fails with an error that points at the wrong layer. You spend forty minutes debugging an image pipeline when the real problem is that you're on Node 20 instead of 22.

The example: a project that broke in four places

Here's a stack a lot of us run: Next.js frontend, a small Python service for embeddings, SQLite for local data, Playwright for a couple of end-to-end tests. Worked flawlessly on an M-series Mac. Cloned onto a fresh Linux box. Four separate breakages, in order.

First, the install itself.

$ npm install
npm error code EBADENGINE
npm error engine Unsupported engine
npm error engine Not compatible with your version of node/npm

The new box had whatever Node the distro shipped. The old one had whatever Node was current when the project started. Nobody wrote the version down.

Second, native modules.

$ npm run dev
Error: Could not load the "sharp" module using the linux-x64 runtime
Possible solutions:
- Ensure optional dependencies can be installed

sharp, better-sqlite3, esbuild, swc — these ship prebuilt binaries per platform. The lockfile records which optional packages exist, but an install done on darwin-arm64 and copied around, or a node_modules restored from a cache built on a different arch, gives you a tree with the wrong binaries in it.

Third, Playwright.

Executable doesn't exist at /root/.cache/ms-playwright/chromium-XXXX/chrome-linux/headless_shell

Playwright's npm package and its browsers are separate downloads. The browsers live in a cache directory outside the project. A committed lockfile does not bring them with you.

Fourth, and this is the one that stings, the agent's own config. The .mcp.json in the repo pointed at a binary that only existed on the old machine:

{
  "mcpServers": {
    "db": {
      "command": "/Users/you/.bun/bin/bun",
      "args": ["run", "scripts/db-mcp.ts"]
    }
  }
}

On the new box that path doesn't exist, the MCP server fails to start, and the agent quietly loses the tool it had been using to inspect the schema. It doesn't error loudly. It just starts guessing, and you don't notice for an hour.

Layer one: pin the runtime, not the vibe

.nvmrc is fine for Node alone, but most of these projects are polyglot. A single file that pins every language and tool is a better trade. mise does this, and it reads asdf-style .tool-versions files if you're migrating.

# mise.toml — committed
[tools]
node = "22.14.0"
python = "3.12.9"
"npm:pnpm" = "9.15.4"

[env]
PLAYWRIGHT_BROWSERS_PATH = "{{config_root}}/.cache/ms-playwright"

Two things happen here. The runtime versions become part of the repo, and the Playwright browser cache moves inside the project instead of living in a per-user directory. That second line fixes breakage number three permanently — the cache is now project-local, gitignored, and rebuilt by the same command everywhere.

For Python, uv covers the same ground natively: uv python pin 3.12 writes a .python-version, and uv won't cross minor versions on its own because a minor bump changes how dependencies resolve. uv lock writes exact pinned versions into uv.lock, and uv lock --check verifies the lockfile is current without modifying it — which is the thing you want in CI.

Layer two: the lockfile is only half a promise

A committed lockfile pins your dependencies. It does not pin the tool that reads it. npm 9 and npm 11 resolve the same package-lock.json differently in edge cases, and swapping between npm, pnpm and yarn on the same repo produces genuinely different trees.

The packageManager field is the lever:

{
  "packageManager": "[email protected]"
}

Corepack reads that and uses the matching version. Worth knowing the sharp edge: Corepack has historically written this field into package.json automatically, which some people actively don't wantCOREPACK_ENABLE_AUTO_PIN=0 turns the auto-pinning off. There's also a newer devEngines.packageManager field that Corepack can use for validation rather than installation. Pick one deliberately instead of letting your tooling pick for you.

And then actually use the lockfile:

# in CI and on any fresh clone
npm ci        # or: pnpm install --frozen-lockfile

npm install will happily update the lockfile to make your problem go away. That's the opposite of what you want on a new machine — it converts "the environment is wrong" into "the dependency graph silently drifted," which is a much harder bug to find later.

Layer three: your agent has config you never audited

This is the part specific to how we work now, and it's the least documented. Claude Code splits configuration across several files with different scopes, and only some of them belong in the repo. Per the official settings docs, ~/.claude.json holds your OAuth session, MCP server configs for user and local scopes, per-project state like allowed tools and trust settings, and assorted caches — while project-scoped MCP servers live separately in .mcp.json.

Read that again with a new machine in mind. Every permission you've ever approved, every trust decision, every user-scope MCP server: home directory. None of it travels. Which means the agent that had been running your test suite without asking now stops and asks for every single command, and the MCP servers you thought were "set up" are only set up for you.

So the split you want is:

  • .claude/settings.json — committed. Permissions, hooks, anything the whole project needs.
  • .claude/settings.local.json — gitignored. Your personal overrides and anything secret.
  • .mcp.json — committed, but only if every command in it resolves on a clean machine.

That last condition is where most repos fail. Fix it by resolving through the project rather than through absolute paths:

{
  "mcpServers": {
    "db": {
      "command": "pnpm",
      "args": ["exec", "tsx", "scripts/db-mcp.ts"],
      "env": { "DATABASE_URL": "${DATABASE_URL}" }
    }
  }
}

Same story for hooks. A hook that shells out to /Users/you/.local/bin/ruff is a landmine; uv run ruff is not. If a settings file is malformed, Claude Code shows a Settings Error dialog at startup and claude doctor reports the details — which is a genuinely useful first command on a fresh box, because it surfaces broken config before you waste an hour wondering why the agent is behaving differently.

The version that actually holds: describe the box

Pinning tools gets you most of the way. It does not get you system libraries, a database, or the right libc. When you want the whole environment described in the repo, use a dev container. The spec is tool-agnostic — a devcontainer.json tells any supporting tool how to build or attach to a container with a defined runtime stack, and there's a CLI as well as editor integrations.

// .devcontainer/devcontainer.json
{
  "name": "app",
  "dockerComposeFile": "compose.yaml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "postCreateCommand": "mise install && pnpm install --frozen-lockfile && pnpm exec playwright install --with-deps chromium",
  "remoteEnv": {
    "PLAYWRIGHT_BROWSERS_PATH": "/workspace/.cache/ms-playwright"
  }
}

The postCreateCommand is the important line and it's the one people under-use. Every manual step you've ever performed on a new machine belongs there. If you found yourself running a command by hand, that's a bug in this file.

Pair it with a compose file that brings up Postgres or Redis, and "set up the project" becomes one action instead of a wiki page nobody maintains.

The tradeoff I'm taking

Containers on macOS are slower for file-heavy work. Bind-mount I/O has real overhead, hot reload feels less snappy, and if your day is spent in a huge monorepo you will feel it. That's the cost. I take it on anything with a database or native dependencies, and I skip it on a pure frontend where mise.toml plus a lockfile is genuinely enough.

Nix is more reproducible than either option — it pins the actual system closure rather than a base image tag that drifts — and I still don't reach for it first, because the learning cliff is steep enough that a teammate hits it and quietly goes back to Homebrew. A setup nobody uses reproduces nothing. Pick the weakest tool that closes your actual failure modes.

The honest gap in the mise-only approach: it pins tool versions, not the OS underneath them. Different glibc, different outcome. If your project touches native compilation, you will eventually need the container.

"I work alone, this is over-engineering"

The new machine is not a hypothetical teammate. It's you, on a new laptop, in eight months. It's you at 2am when your SSD dies. It's CI, which is a fresh machine every single run. And increasingly it's your agent, running in a sandbox or a cloud environment that has none of your local state — the more of your workflow you hand to agents, the more the repo has to stand on its own, because the agent cannot read your shell history.

There's a second-order effect too. When the environment is declared in the repo, the agent can read it. Ask Claude Code to add a service and it knows what Python version you're on, what's in compose, what the post-create step does. Leave it undeclared and the agent infers from whatever it finds on PATH, which is how you end up with a Dockerfile pinned to a Node version you don't actually run.

Prove it by destroying it

None of this is real until you test it, and you cannot test it on the machine that already works. That machine is contaminated. Do this instead:

git clone <your-repo> /tmp/coldstart && cd /tmp/coldstart
docker run --rm -it -v "$PWD":/w -w /w mcr.microsoft.com/devcontainers/base:ubuntu bash
# inside, run only what your README/postCreateCommand says to run

Every time you reach for a command that isn't in the repo, stop and add it. Every env var you type from memory goes into .env.example with a comment about where to get the value. That loop usually takes two or three passes and about an hour, and it ends with a repo that a stranger — or a fresh agent session — can start cold.

Do this one thing this week

Pick the project you'd be most annoyed to lose. Run the cold-start test above once, and write down every manual intervention. Then put them in a postCreateCommand or a mise.toml, and run it again until it's clean. Not the whole stack, not every repo — one project, one hour.

If you get a weird failure halfway through, drop it in the club Discord. Someone's almost certainly hit the same one: discord.gg/3scUHe7B.

Timothy Yang

Timothy Yang

Founder & CEO, DrillCall

Four businesses built and exited, including a micro-task marketplace with 170,000+ users. Now building DrillCall and running Vibe Coding Club from Sydney.

Build with us

Vibe Coding Club is where people who ship with AI tools compare notes. Bring what you're building.

JOIN THE DISCORD →