Dependencies Install Fine but the Build Won't Run? Lock Your Build Environment in Three Layers
Dependencies Install Fine but the Build Won't Run? Lock Your Build Environment in Three Layers Many engineers treat "it runs" as luck. It works…
- Added to the lab
- Downloads
- 0
- Human review
- Human reviewed
Dependencies Install Fine but the Build Won't Run? Lock Your Build Environment in Three Layers
Many engineers treat "it runs" as luck. It works on your machine, then a fresh clone or a different CI runner blows up. In reality, most of these incidents trace back to one habit: installing dependencies with caret ranges while the lockfile is missing or effectively ignored.
How drift creeps in
Writing "foo": "^2.1.0" in package.json sounds like you pinned the major version. In practice, the resolver picks whatever is newest in the 2.x range at install time. Today you get 2.1.3 and everything is fine; next month a fresh clone pulls 2.7.0, where some subtle behavior changed — a default encoding, a tightened peer dependency, a renamed exported type — and your build goes red. Worse, the failure happens on someone else's machine, so reproducing it costs half a day.
Drift happens at three layers, and most people only lock the first:
- Direct dependency drift — what a lockfile solves.
- Transitive dependency drift — A depends on B
^1.0; the lockfile must be committed, or every machine resolves differently. - Environment drift — Node/Python version, system libraries, timezone, locale. A lockfile cannot cover this layer.
Lock each layer with the right tool
- JavaScript: commit the lockfile, freeze it in CI. Use
npm ci(orpnpm install --frozen-lockfile), never plainnpm install.npm ciinstalls exactly what the lockfile says and fails immediately if it disagrees with package.json — failing in CI beats failing in production. The lockfile belongs in git; that is not optional. - Python: lock the full resolution. Use pip-tools (requirements.txt compiled from requirements.in) or
uv lock. Listing only top-level packages in requirements.txt is not locking. - Environment: declare the runtime explicitly. Use
.nvmrcor theenginesfield for Node,.python-versionfor Python. Pin Docker base images to patch-level tags likenode:22.14.1-bookworm, neverlatest.
Upgrade deliberately, in batches, reversibly
Locking is not refusing to upgrade. The right cadence: you decide when to upgrade (Renovate or Dependabot opens PRs), every bump runs the full CI suite, the diff is reviewable in the PR, and a bad upgrade reverts with a single commit. Without locking, upgrades happen to you — some reinstall silently pulls a new version, tests never covered it, and users find out before you do. For security advisories, bump just that package, run CI, merge. Far more controllable than shipping daily against unknown drift.
Fixing a broken lockfile
Merge conflicts in lockfiles are a common nightmare. Do not hand-stitch them: delete the lockfile on the target branch, regenerate it so the resolver recomputes the whole tree, and commit the result with the diff. Editing version numbers by hand bypasses the resolver and almost guarantees an "installs but misbehaves" ghost state. In monorepos, pnpm's workspace:* protocol keeps internal packages pointed at local versions and swaps them for real versions at publish time — the least painful option available today.
A quick self-check
- Is there a lockfile in the repo, tracked by git (not accidentally in .gitignore)?
- Does CI run
npm ci/--frozen-lockfile? - Are Docker base tags pinned to patch level?
- Is the Node/Python version declared in a file?
- Do upgrades go through PRs, or did someone run
npm updateand hope?
If two or more of these fail, "it works on my machine" is already a liability that has not matured yet. Locking your environment turns when and to what you upgrade from a random event into a decision you own — and ten minutes per upgrade is the best-return investment in this entire article.