DEV Community

cometa-kaito
cometa-kaito

Posted on Originally published at tech.rebounder.jp

pnpm --filter build "Module not found": Use web...

Conclusion

In a pnpm workspace monorepo, running pnpm --filter web build inside Docker to build only the target package left the workspace packages it depends on without a generated dist, and Turbopack's resolution attempt threw 93 "Module not found" errors. Appending ... to the end of the --filter target name — so install and build both cover "the target package and its dependencies" — fixed it.

Symptom

In a pnpm workspace of apps/* and packages/*, apps/web depends on packages/ai, packages/db, and packages/observability. Trying to build just this web app in the Cloud Run apps/web/Dockerfile produced problems at two separate stages.

First, the deps stage only copied the package.json files for apps/web and packages/db.

COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/web/package.json apps/web/package.json
COPY packages/db/package.json packages/db/package.json

RUN pnpm install --frozen-lockfile
Enter fullscreen mode Exit fullscreen mode

Running pnpm install --frozen-lockfile against the whole workspace while the package.json files for packages/ai and packages/observability — both dependencies of apps/web — were missing meant the workspace structure recorded in the lockfile didn't match the files actually copied in, and install failed on a lockfile mismatch.

Even after getting install to pass, the builder stage also scoped the build to web alone.

RUN pnpm --filter @scope/web build
Enter fullscreen mode Exit fullscreen mode

This command only runs @scope/web's own build script. The build scripts for packages/ai, packages/db, and packages/observability never run, so none of their dist/ directories get generated. apps/web's source imports those workspace dependencies through node_modules, but with the resolved dist missing, the Turbopack build used by Next.js 16 couldn't resolve the dependency packages' entry points and threw 93 "Module not found" errors.

Cause

pnpm --filter <package-name> scopes the command's execution target to only the named package itself. It never propagates to other packages in the workspace. This holds for both install and build.

  • When install hits this constraint: --frozen-lockfile checks consistency against the whole workspace's lockfile, so copying in only a subset of package.json files while scoping the target creates a mismatch against what the lockfile expects, and install fails
  • When build hits this constraint: the build script doesn't propagate outside the target package, so the dependency packages' dist is never built and stays missing

pnpm expresses "the target package plus its dependencies" by appending ... to the end of the package name. Without ..., --filter @scope/web refers literally to @scope/web alone and never walks the dependency graph.

Fix

We stopped splitting out a separate deps stage, copied in the whole monorepo along with its workspace structure, and used a ...-suffixed filter for both install and build.

# Pull in the whole monorepo (node_modules/.next etc. already excluded via .dockerignore)
COPY . .

# Install only @scope/web and its dependency subtree (ai / db / observability + transitive)
RUN pnpm install --frozen-lockfile --filter @scope/web...

# ...

# `...` includes web's workspace dependencies (ai / db / observability) in the build too.
# pnpm's recursive run executes in dependency order (dependencies first),
# so ai/db/observability's dist is generated first and web becomes resolvable
RUN pnpm --filter @scope/web... build
Enter fullscreen mode Exit fullscreen mode

pnpm install --frozen-lockfile --filter @scope/web... with ... appended installs against the whole workspace's package.json set, scoped to @scope/web and its dependency subtree. Since we now copy in everything, no files get dropped by the scoping.

We changed the build side the same way, to pnpm --filter @scope/web... build. pnpm's recursive execution resolves the dependency graph and runs each package's script in dependency order (dependents run after what they depend on), so ai, db, and observability build first and their dist is in place by the time web's build runs, letting Turbopack resolve the dependency packages.

Recurrence risk

Nothing in this record describes adding a check that verifies --filter's scope in CI. This particular case was fixed as a single missing ..., but the underlying structure of pnpm workspaces — a scoped command not propagating to its dependencies — hasn't changed. Writing another script or Dockerfile stage that scopes a command to a single target again leaves room for the same symptom to reappear.


Originally published at tech.rebounder.jp.

Top comments (0)