DeployLesson
pnpm and the lockfile
package.json names version ranges, so two installs a week apart can get different packages. A lockfile records the exact version of every package, direct or indirect, and an install that follows it gets the same tree each time. Terrarium commits pnpm-lock.yaml, and both the Gate in CI and the live container refuse to install when it disagrees with package.json.
Know pnpm and lockfiles already? Skip to In Terrarium.
Outside the box · General
The concept
A package manager reads each range in package.json, such as ^3.25.76, picks one version, and does the same for every package those depend on. The lockfile writes the result down: every package's exact version and an integrity hash of its contents. Committed, it lets each install repeat the tree you tested instead of resolving again (pnpm: Working with Git).
A frozen install is the strict form. With pnpm install --frozen-lockfile, pnpm never writes the lockfile and fails if it doesn't match package.json; in CI that is the default. A plain install elsewhere updates the lockfile to match instead.
Which pnpm a project expects is the packageManager field of package.json, which pnpm and Corepack both read.
Full referencepnpm docs: Settings
In Terrarium
package.json pins pnpm itself:
5 "packageManager": "pnpm@10.33.0",
CI's setup step, pnpm/action-setup, reads that field when it is given no version, and the live container's image enables Corepack, which reads it too (deploy/Dockerfile). Each range in package.json resolves to one exact version in pnpm-lock.yaml:
17 zod:
18 specifier: ^3.25.76
19 version: 3.25.76
Two installs refuse a lockfile that disagrees. The installs in .github/actions/gate/action.yml:
24 - name: Install
25 shell: bash
26 run: pnpm install --frozen-lockfile
The live container installs in deploy/entrypoint.sh, into a pnpm store on its volume:
57install_deps() {
58 rm -f "$INSTALLED_HASH"
59 log "pnpm install (frozen lockfile)…"
60 ( cd "$REPO_DIR" && pnpm install --frozen-lockfile --store-dir "$STORE_DIR" ) &&
61 lockhash > "$INSTALLED_HASH"
62}
It runs at first boot, and after a new commit only when the lockfile's hash differs from the one saved by the last good install (Continuous deployment follows that loop).
Local installs are plain. Each Claude Code session starts with pnpm install (.claude/settings.json), and pnpm gate:scoped runs one before its first step when package.json or the lockfile is newer than node_modules/.pnpm (scripts/gate/gate.ts).
The repo has no pnpm-workspace.yaml or .npmrc, and package.json has no pnpm field: pnpm runs on its defaults. Since pnpm 10 those skip every dependency's install scripts, the usual way a compromised package runs code (pnpm: Supply chain security): each install prints the ones it skipped, and pnpm ignored-builds lists them. The project's own postinstall runs: it points git at .githooks/ and runs nuxt prepare.
pnpm puts only a project's declared dependencies at the top of node_modules (pnpm: Motivation): Nuxt Content depends on jiti, which alone would leave it only in node_modules/.pnpm; package.json also declares jiti as a dev dependency of its own, which puts it at the top level, where shared/expand.ts imports it to load the manifests. package.json is on the list (Protected paths); pnpm-lock.yaml is not.
Why it's built this way
- Install what the Gate tested. A plain install in CI could update the lockfile on the fly and test a tree nobody committed. The Gate and the container both install frozen, from the same committed lockfile. The cost: a
package.jsonedit without a matching lockfile fails the Gate at its first step. .github/actions/gate/action.yml and deploy/entrypoint.sh. - A new package is new code on the server. Every merge runs on the server, its install and build included, and the Gate checks a package only from the outside. So a person merges a PR that adds a dependency. The cost: even a dev-only tool waits for a person. ADR-0004 (docs/adr/0004-objective-safety-gate.md).
- A stale install looks like a real break. Agents took type errors from an old
node_modulesfor a brokenmain.pnpm gate:scopednow installs first when the manifest or lockfile is newer than the install. The cost: that install isn't frozen, so it can rewrite the lockfile. scripts/gate/gate.ts. - No patched packages. A content-loading bug was once fixed with a
pnpm patchof Nuxt Content's built code, wired in by apnpm-workspace.yamlthat held nothing else. A patch is tied to one version's compiled code and must be redone at every upgrade, so it was dropped for an error dialog in the app (the Midden keeps it). The cost: that failure now needs a reload. ADR-0019 (docs/adr/0019-content-load-error-recovery.md).
What to watch for
- Commit
package.jsonand the lockfile together. A range changed by hand, with nopnpm installafter it, stops the Gate's install withERR_PNPM_OUTDATED_LOCKFILE.pnpm addandpnpm removechange both files at once. - Don't fix the lockfile by hand. After a merge conflict in
pnpm-lock.yaml, runpnpm installand pnpm resolves it; read the diff before you commit, since it may not choose the side you meant (pnpm: Merge conflicts). - Import only what you declare. A package that only a dependency brings in can't be imported from Terrarium's code. Adding it with
pnpm add -Dchangespackage.json, so a person merges the PR. - A lockfile-only change passes the path check. So does adding the
pnpm-workspace.yamlthatpnpm patch-commitwrites, or thatpnpm approve-buildswrites to let the dependencies you approve run their install scripts, yet a patched or new package still needs a person (ADR-0004 (docs/adr/0004-objective-safety-gate.md)); Protected paths says what the script can't see. - Read the docs for your pnpm. The unversioned pages on pnpm.io describe a newer major, whose settings can differ. Read the versioned pages for the major that
packageManagernames, such as/10.x/.
Try it in your project
In a project with a committed pnpm-lock.yaml, change one dependency's range in package.json without installing, then run pnpm install --frozen-lockfile. It should stop with ERR_PNPM_OUTDATED_LOCKFILE and name the package, with the lockfile's range and yours. Undo the edit, then run pnpm ignored-builds to see which dependencies' install scripts pnpm skipped.
Where next
- LessonContinuous deployment
- LessonProtected paths
- LessonMerge gate
- JournalArchitecture & Deployment
Walkthroughs through this Lesson
- WalkthroughA change goes live
Last updated · Latest source on GitHub ↗