Next.js standalone builds trace only what they can reach from the app directory. In a Rush/pnpm monorepo the real packages live outside it, so your Docker image boots with missing modules. Here's why, and the one-line fix.
The build is green. .next/standalone is generated. You copy it into a slim Docker image, start node server.js, and the container dies on boot:
Error: Cannot find module 'react'
Require stack:
- /app/node_modules/next/dist/server/...
code: 'MODULE_NOT_FOUND'
It works on your machine, and next start works too. Only the standalone bundle is broken, and only inside a monorepo managed by Rush with pnpm.
With output: "standalone", Next.js doesn't copy your whole node_modules. It runs a file tracer (@vercel/nft) that walks the require / import graph from your server entry points and copies only the files that are actually reachable into .next/standalone.
That's what makes the image small. It also means the result is only as good as the tracer's view of the filesystem.
pnpm doesn't install packages into each project's node_modules. It keeps one content-addressed store and builds a symlink farm. Rush takes this further by hoisting everything into a shared install location:
repo/
├── common/
│ └── temp/
│ └── node_modules/
│ └── .pnpm/
│ ├── next@15.x/node_modules/next ← real files
│ ├── react@19.x/node_modules/react ← real files
│ └── ...
└── apps/
└── web-app/
└── node_modules/
├── next -> ../../../common/temp/node_modules/.pnpm/next@15.x/...
└── react -> ../../../common/temp/node_modules/.pnpm/react@19.x/...
Inside apps/web-app/node_modules, every dependency is just a symlink. The real files sit outside the app directory, several levels up.
The tracer's default root is the app directory. So:
common/temp/..., and finds that path is outside its root.node_modules that is missing packages, or contains dangling structure.Nothing fails at build time. The tracer doesn't know it missed anything. The failure only appears when node server.js tries to require() a package that was never copied, which is usually inside Docker, long after CI went green.
Next.js lets you widen the tracer's root with outputFileTracingRoot. Point it at the monorepo root so the real pnpm store paths fall inside it:
// next.config.ts
import path from "node:path";
import type { NextConfig } from "next";
const config: NextConfig = {
output: "standalone",
// apps/web-app -> repo root
outputFileTracingRoot: path.join(__dirname, "../../"),
};
export default config;Now the tracer sees common/temp/node_modules/.pnpm/... as part of its world, resolves the symlinks to real files, and copies them.
With a wider root, Next.js mirrors the repo structure inside the standalone folder. Your server entry is no longer at .next/standalone/server.js; it's nested:
.next/standalone/
├── apps/
│ └── web-app/
│ ├── server.js ← new entry point
│ └── .next/
└── node_modules/ ← resolved dependencies
Update your Dockerfile accordingly:
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /repo/apps/web-app/.next/standalone ./
COPY --from=build /repo/apps/web-app/.next/static ./apps/web-app/.next/static
COPY --from=build /repo/apps/web-app/public ./apps/web-app/public
CMD ["node", "apps/web-app/server.js"]The static and public folders are not included in standalone output, and they must be copied to the nested app path, not the root.
common/config/rush/pnpm-lock.yaml, which the heuristic doesn't recognize, so don't rely on inference.node apps/web-app/server.js from the standalone folder on a machine without the monorepo's node_modules. If it boots there, Docker will boot.require(someVariable). If something is still missing after the root fix, add it via outputFileTracingIncludes.node_modules wholesale. It works, and it throws away the size benefit that made you pick standalone in the first place.Standalone tracing assumes the files your app needs live under the app. pnpm symlinks, and Rush's shared install location in particular, break that assumption quietly. Set outputFileTracingRoot to the monorepo root, adjust your Docker paths for the nested layout, and test the bundle in isolation before it reaches an image.
It's one line of config, but only after an afternoon of staring at a container that boots fine everywhere except production.