Using pnpm Workspaces in a DevContainer
pnpm's content-addressable store makes monorepo installs fast, but only if the store is cached and installs are frozen. This page sets up pnpm workspaces in a devcontainer with the store on a named volume and --frozen-lockfile installs, so a monorepo rebuild is quick and reproduces the exact dependency tree.
This matters most when a devcontainer is disposable by design. Every time you rebuild the container, delete it and start fresh, or hand it to a new teammate, the filesystem inside starts empty. If the pnpm store lives on that ephemeral filesystem, a monorepo with dozens of workspace packages re-downloads and re-unpacks every dependency on the first install — the exact tax the content-addressable store exists to remove. By moving the store to a named volume that outlives the container, you turn a cold, minutes-long install into a warm one that resolves in seconds because pnpm only has to hard-link packages that already sit in the store. The store is deduplicated globally, so a version of react or typescript shared by ten packages is stored once and linked ten times.
The mental model to carry through the rest of this page is a split between speed and determinism, driven by two independent levers. Speed comes from the store: keep it warm on a volume and installs reuse work across rebuilds. Determinism comes from two pins — corepack fixes the pnpm binary version so every developer runs the same resolver, and --frozen-lockfile fixes the dependency graph so an install reproduces pnpm-lock.yaml byte-for-byte instead of quietly resolving newer versions. Reach for this setup whenever a monorepo's install time starts dominating the rebuild loop, or whenever "works on my machine" bugs trace back to two developers holding subtly different dependency trees. The two levers are orthogonal: you can have a fast install that drifts, or a reproducible install that is slow, and this page wires up both at once.
Prerequisites
You need a pnpm monorepo and a volume for the pnpm store.
- A monorepo with
pnpm-workspace.yaml. - pnpm pinned via corepack.
- A named volume for the pnpm content-addressable store.
The one prerequisite people underestimate is the store location. pnpm's store defaults to a path under the home directory of the user running the install — for the node user in the standard devcontainer image that is /home/node/.local/share/pnpm/store. The named volume you mount has to target that exact directory, not the project's node_modules and not a generic cache folder, because node_modules inside a workspace is a tree of hard links and symlinks that point back into the store rather than a self-contained copy. Mount the wrong path and you get a volume that fills up but never speeds anything up, because the store itself is still being rebuilt inside the container each time.
The corepack pin is the second detail worth checking before you start. Corepack ships with modern Node but is disabled by default, and the version it hands you is governed by the packageManager field in the root package.json — a line like "packageManager": "pnpm@9.12.0". Without that field, corepack pnpm falls back to whatever version corepack considers current, which reintroduces exactly the per-developer drift the setup is meant to eliminate. Confirm the field exists and names an exact version, not a range, so the workspace resolves identically for everyone who opens it.
Step-by-Step Implementation
- Declare the workspace and pin pnpm via corepack.
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
The pnpm-workspace.yaml file is what turns a directory of folders into a single workspace: the glob patterns under packages tell pnpm which subdirectories contain packages it should link together. Placing it at the repository root is not optional — pnpm treats the directory holding this file as the workspace root, and every workspace command resolves relative to it. The apps/* and packages/* split is a common convention where deployable applications live under apps and shared libraries under packages, but the globs are arbitrary; what matters is that each matched directory has its own package.json. Get the globs wrong and pnpm silently treats those packages as external, so cross-package imports resolve against the registry instead of your local source.
- Cache the pnpm store on a named volume.
{
"features": { "ghcr.io/devcontainers/features/node:1": { "version": "20" } },
"mounts": ["source=devcontainer-pnpm-store,target=/home/node/.local/share/pnpm/store,type=volume"],
"postCreateCommand": "corepack enable && corepack pnpm install --frozen-lockfile",
"remoteUser": "node"
}
This devcontainer.json wires the three pieces together. The node:1 feature installs a pinned Node 20 so the base toolchain is fixed, while the mounts entry attaches the named volume devcontainer-pnpm-store to the store path pnpm expects for the node user. Because the source is a bare volume name rather than a bind mount from the host, Docker manages its lifecycle independently of the container, so the store survives rebuilds and is shared cleanly between them. The postCreateCommand runs corepack enable before the install so the pinned pnpm from packageManager is active, then performs the frozen install once, when the container is created rather than on every attach. Setting remoteUser to node is what makes the mounted store path line up: the command runs as node, and the volume is mounted at that user's home, so the store is both writable and located exactly where pnpm looks for it, avoiding the root-owned-cache permission trap.
- Install with frozen lockfile so the tree is reproducible.
corepack pnpm install --frozen-lockfile
Running the install through corepack pnpm rather than a globally installed pnpm guarantees you get the version named in packageManager, closing the gap where a stray global install shadows the pinned one. The --frozen-lockfile flag is the determinism lever: it tells pnpm to install strictly from pnpm-lock.yaml and to error out if the lockfile would need to change to satisfy the manifests. That failure is a feature, not an obstacle — it surfaces the moment someone edits a package.json without regenerating the lockfile, instead of letting the container drift to a different tree than the one committed. In a devcontainer and in CI this is the flag that makes local-equals-CI a guarantee rather than a hope; you regenerate the lockfile deliberately with a plain pnpm install on your own machine and commit the result, and every frozen install downstream reproduces it exactly.
- Verify the store is reused and the workspace links.
pnpm -r list --depth -1 # all workspace packages resolve
The -r flag runs the command recursively across every package the workspace globs matched, and --depth -1 trims the output to just the top-level package names so you get a roster of what pnpm considers part of the workspace. If a package you expected is missing from that list, the workspace globs in pnpm-workspace.yaml did not match it, and its cross-package imports will be resolving against the registry rather than your local source. To confirm the store itself is being reused rather than rebuilt, watch the timing on a second install after a container rebuild: a warm store reports packages as reused and finishes in seconds, whereas a cold one shows them being fetched and added.
Common Pitfalls
pnpm workspace issues are an uncached store, a non-frozen install, or the wrong workspace root.
The ownership trap is the most common one when a volume is involved. The store volume is mounted at /home/node/.local/share/pnpm/store, and that path only works if the process writing to it runs as node. If a postCreateCommand or a manual install runs as root — easy to do if you forget remoteUser or exec into the container as root — the store directory and the packages under it are written with root ownership. The next install as node then fails with EACCES permission errors on a cache it cannot write, and the confusing part is that a fresh container works fine while a rebuilt one does not, because the offending root-owned files persist on the volume. The fix is to keep every write to the store on the node user and, if a volume is already poisoned, reset ownership with chown -R node:node on the store path or discard and recreate the volume.
The second topic-specific pitfall is running installs from the wrong directory. pnpm only recognizes the workspace when it can see pnpm-workspace.yaml above it, so an install launched from inside apps/web or packages/ui resolves that single package in isolation and skips the linking that makes workspace:* dependencies point at local source. The symptom is subtle: the install succeeds, but a package that should import a sibling library instead pulls a published version from the registry, and type errors or stale behavior follow. Always drive installs from the repository root — which is exactly why the postCreateCommand runs there — and if you script per-package work, use pnpm's own --filter flag from the root rather than changing directories into a package.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Store re-downloaded each rebuild | Store not on a volume | Mount the pnpm store on a named volume |
| Install changes the lockfile | Not using --frozen-lockfile | Install with --frozen-lockfile |
| Workspace packages not linked | Ran outside the workspace root | Install from the root |
| Different pnpm version per dev | pnpm not pinned | Pin via corepack packageManager |
Conclusion
pnpm plus a cached store is the fastest reproducible monorepo setup. Pin pnpm with corepack, mount its content-addressable store on a named volume so rebuilds reuse packages by hard-link, and install with --frozen-lockfile from the workspace root so the dependency tree is exactly reproduced. Fast and deterministic, together.
The strategic payoff is that the rebuild loop stops being a reason to avoid rebuilding. When a fresh container costs minutes of dependency downloads, teams cling to long-lived containers, let them accumulate drift, and lose the whole point of a disposable environment. With the store on a volume and installs frozen, tearing down and recreating the container becomes cheap enough to do routinely, which keeps every environment honest against the committed lockfile. That is the same pin-and-cache pattern that governs every reproducible devcontainer: pin the exact inputs — here the pnpm version through corepack and the dependency tree through pnpm-lock.yaml — and cache the expensive, deterministic outputs — here the unpacked packages in the content-addressable store — on storage that survives a rebuild.
Read against the broader theme, the two levers map cleanly onto the two properties you want from any environment. The named volume is the caching half, and it is safe precisely because pnpm's store is content-addressable: entries are keyed by the hash of their contents, so a cached package can never be the wrong package. The corepack pin and --frozen-lockfile are the reproducibility half. Applied to a pnpm monorepo, the result is a workspace where a new teammate clones the repo, opens the container, and lands on the identical dependency tree everyone else is running — in seconds, not minutes, and without a single manual step.
FAQ
Why is pnpm faster than npm in a monorepo?
pnpm uses a content-addressable store and hard-links packages into each workspace rather than copying them, so shared dependencies are downloaded and stored once. Cache that store on a named volume, and a rebuild links from it in seconds instead of re-downloading — especially impactful across many workspace packages. npm, by contrast, tends to copy a full dependency tree into each package's node_modules, which multiplies both disk usage and install time as the number of packages grows. Because pnpm's links point back into a single shared store, ten packages depending on the same version of a library cost you one copy on disk, not ten, so the savings compound with the size of the monorepo.
What does --frozen-lockfile do?
It makes the install fail rather than modify pnpm-lock.yaml, guaranteeing the exact locked dependency tree is reproduced. Without it, an install can silently resolve new versions and change the lockfile, breaking the local-equals-CI guarantee. Always use it in a devcontainer and CI. The mental shift is to treat the lockfile as an input you regenerate deliberately, not a side effect of installing: you run a plain pnpm install on your own machine when you intend to change dependencies, review and commit the updated lockfile, and every frozen install after that reproduces it. If a frozen install fails, it is telling you a package.json and the lockfile have diverged — fix that mismatch rather than dropping the flag.
Where should I run the install in a workspace?
From the workspace root, so pnpm sees pnpm-workspace.yaml and links all packages together. Running an install inside a single package can miss the workspace linkage. Pin the pnpm version with corepack so every developer resolves the workspace identically. If you need to act on just one package, stay at the root and use pnpm's --filter flag, for example pnpm --filter web build, which targets that package by name without leaving the workspace context. Changing directories into a package to run commands is the habit that quietly breaks workspace:* linkage.
Should the pnpm store be a named volume or a bind mount?
Prefer a named volume, as the mounts entry here does. A named volume is managed by Docker, lives independently of both the container and the host project directory, and avoids the host-filesystem ownership and performance quirks that bind mounts bring — particularly on macOS and Windows where bind-mount I/O is slow. The store is pure cache keyed by content hash, so there is no reason to expose it on the host; a named volume keeps it fast and disposable.
Do I still commit node_modules or the store to the repository?
No. You commit pnpm-lock.yaml, pnpm-workspace.yaml, and the packageManager field, and nothing else about installed dependencies. The lockfile plus the pinned pnpm version is enough to reconstruct the exact tree anywhere, and the store is rebuilt or reused from the volume on demand. Committing node_modules would defeat the hard-linking model — those are links into the store, not portable files — and bloat the repository. Keep node_modules and any local store path in .gitignore and let the frozen install rebuild the tree deterministically.
Related
- Up to Node.js & TypeScript Workspace Configuration — the parent guide on Node setup.
- Fixing Node.js npm Cache in DevContainers — caching for the npm alternative.
- Configuring TypeScript Path Aliases in a DevContainer — resolving aliases across workspace packages.