iTranslated by AI
5 Pitfalls with pnpm + Next.js standalone + Docker [Part 9]
What You Will Learn in This Article
- Why the pnpm symlink structure breaks with Next.js standalone
- How to handle cases where
cp -rLdoesn't solve the problem - Symlink resolution patterns in Docker multi-stage builds
- Lessons learned from 5 fix PRs
Background
Saru manages five Next.js frontends (Landing / System / Provider / Reseller / Consumer) in a pnpm monorepo. Since we run them using volume mounts in the development environment, a Dockerfile isn't necessary. However, for deploying to production and demo environments, we need Docker images.
By using Next.js's output: 'standalone', it traces only the necessary files to .next/standalone/. I thought if I copied this to an Alpine-based runner stage, I could create a lightweight image—or so I thought.
Issue 1: MODULE_NOT_FOUND (#557)
Symptoms
Error: Cannot find module 'next/dist/compiled/next-server/app-page.runtime.prod.js'
The container crashed the moment I ran docker run.
Cause
pnpm builds node_modules based on symlinks. For example:
standalone/node_modules/next → ../../node_modules/.pnpm/next@14.2.x/node_modules/next
Next.js's @vercel/nft (Node File Tracing) copies this symlink structure as-is into the standalone output. This isn't an issue within the builder stage because the symlink targets exist, but when you bring it over to the runner stage with COPY --from=builder, the directory the symlink points to does not exist.
builder (at standalone creation) runner (after COPY)
├── standalone/ ├── standalone/
│ └── node_modules/ │ └── node_modules/
│ └── next → ../../.pnpm/... │ └── next → ../../.pnpm/...
└── node_modules/.pnpm/... ✅ exists └── (none) ❌
Solution Tried
RUN cp -rL /app/apps/system/.next/standalone /app/standalone
cp -rL is a POSIX command that follows symlinks and copies the actual files. I thought this would be a one-shot fix.
Results: ❌ Failed
Issue 2: dangling symlink (#560)
Symptoms
cp: can't stat '/app/apps/system/.next/standalone/node_modules/next': No such file or directory
cp -rL itself failed with an error.
Cause
Some symlinks in the standalone node_modules pointed to the pnpm virtual store outside of the standalone directory. Symlinks to paths not included within the standalone directory become "dangling symlinks" (broken symlinks). cp -rL stops when it encounters a dangling symlink.
standalone/node_modules/
├── next → ../../node_modules/.pnpm/next@14.2.x/node_modules/next ← dangling
├── react → ./react (entity exists within standalone) ← OK
└── ...
Solution Tried
"If batch processing doesn't work, let's resolve them one by one."
RUN cd /app/apps/system/.next/standalone/node_modules \
&& cp -a . /tmp/nm-backup \
&& for mod in *; do \
[ -L "$mod" ] || continue; \
target=$(readlink -f "$mod" 2>/dev/null); \
if [ -e "$target" ]; then \
rm "$mod" && cp -r "$target" "$mod"; \
else \
rm "$mod"; \
fi; \
done
A method where we check symlinks one by one: copy if the entity exists, and skip if it's dangling.
Results: ❌ Failed (for another reason)
Issue 3: Path Mismatch in Resolution (#561)
Symptoms
At first glance, the build succeeded, but it still resulted in MODULE_NOT_FOUND. However, the error occurred in a different module than in Issue 1.
Cause
In the method used in Issue 2, symlinks were resolved after being copied to /app/standalone. However, pnpm symlinks are written as relative paths:
next → ../../node_modules/.pnpm/next@14.2.x/node_modules/next
This relative path resolves correctly from /app/apps/system/.next/standalone/node_modules/, but from /app/standalone/node_modules/, the path shifts and points to a different location.
Furthermore, some modules existed only in the root .pnpm store rather than the app-level node_modules.
Solution Tried
"Instead of resolving after copying, let's resolve at the original location (where the path is correct) and then copy."
RUN cd /app/apps/system/.next/standalone/node_modules \
&& for mod in *; do \
[ -L "$mod" ] || continue; \
target=$(readlink -f "$mod" 2>/dev/null); \
if [ -e "$target" ]; then \
rm "$mod" && cp -r "$target" "$mod"; \
else \
rm "$mod"; \
real=$(find /app/node_modules/.pnpm -path "*/$mod/package.json" \
! -path "*/node_modules/*/node_modules/*" 2>/dev/null | head -1); \
[ -n "$real" ] && cp -r "$(dirname "$real")" "$mod" || true; \
fi; \
done \
&& cp -r /app/apps/system/.next/standalone /app/standalone
For dangling symlink modules, the method involves using find to search for and copy them from the root .pnpm store.
Results: ❌ Failed (yet again for another reason)
Issue 4: Transitive deps and Scoped Packages (#562)
Symptoms
Error: Cannot find module 'styled-jsx'
Error: Cannot find module '@swc/helpers'
next itself could now be found, but styled-jsx and @swc/helpers, which next depends on, could not be found.
Cause
The pnpm store structure has an important characteristic:
node_modules/.pnpm/next@14.2.x/
└── node_modules/
├── next/ ← Package body
├── styled-jsx/ ← next dependency (sibling)
├── @swc/
│ └── helpers/ ← scoped package dependency (sibling)
└── react/ ← next dependency (sibling)
pnpm places package dependencies as siblings within the same directory. In the method used in Issue 3, only the module body was being copied, and I had forgotten to copy the siblings (transitive dependencies).
Furthermore, scoped packages like @swc/helpers are located under the @swc/ directory, and there are other symlinks within that directory as well. A simple cp -r would break these internal symlinks.
Solution
"Instead of copying the module alone, copy all siblings of the store entry using cp -rL."
RUN cd /app/apps/system/.next/standalone/node_modules \
&& for mod in *; do \
[ -L "$mod" ] || continue; \
rm "$mod"; \
pkg=$(find /app/node_modules/.pnpm \
-path "*/node_modules/$mod/package.json" 2>/dev/null | head -1); \
if [ -n "$pkg" ]; then \
store_nm=$(dirname "$(dirname "$pkg")"); \
for dep in "$store_nm"/*; do \
dep_name=$(basename "$dep"); \
[ -e "$dep_name" ] && ! [ -L "$dep_name" ] && continue; \
[ -L "$dep_name" ] && rm "$dep_name"; \
cp -rL "$dep" "$dep_name" 2>/dev/null || true; \
done; \
fi; \
done \
&& cp -r /app/apps/system/.next/standalone /app/standalone
Key points:
- When a symlink is found, look for that module within the pnpm store.
- If the module is found, copy all siblings in the same directory.
- By using
cp -rL, nested symlinks within the siblings are also resolved.
Results: ✅ MODULE_NOT_FOUND resolved
Issue 5: 404 on Static Assets (#565)
Symptoms
The container started. HTTP requests returned responses. However, when opening the page, all CSS/JS resulted in 404 errors.
Cause
# Before fix
COPY --from=builder --chown=nextjs:nodejs /app/apps/system/.next/static ./apps/system/.next/static
Next.js standalone's server.js looks for .next/static relative to its own directory. In other words, it expects /app/.next/static. However, in the Dockerfile, I was copying it to /app/apps/system/.next/static, maintaining the original path structure of the monorepo.
Solution
# After fix
COPY --from=builder --chown=nextjs:nodejs /app/apps/system/.next/static ./.next/static
A one-line change. But to notice this, I had to check the 404s in the browser's DevTools and read the server.js code.
Results: ✅ Fully functional
Final Dockerfile
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
COPY turbo.json ./
COPY apps/system/package.json ./apps/system/
# Copy package.json of all workspace packages
COPY packages/types/package.json ./packages/types/
COPY packages/ui/package.json ./packages/ui/
COPY packages/auth/package.json ./packages/auth/
COPY packages/api-client/package.json ./packages/api-client/
COPY packages/config/package.json ./packages/config/
COPY packages/env-validator/package.json ./packages/env-validator/
COPY packages/logger/package.json ./packages/logger/
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/system/node_modules ./apps/system/node_modules
COPY --from=deps /app/packages ./packages
COPY apps/system ./apps/system
COPY packages ./packages
COPY turbo.json pnpm-workspace.yaml package.json ./
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN pnpm turbo run build --filter=system
# pnpm symlink resolution (this is the core of this article)
RUN cd /app/apps/system/.next/standalone/node_modules \
&& for mod in *; do \
[ -L "$mod" ] || continue; \
rm "$mod"; \
pkg=$(find /app/node_modules/.pnpm \
-path "*/node_modules/$mod/package.json" 2>/dev/null | head -1); \
if [ -n "$pkg" ]; then \
store_nm=$(dirname "$(dirname "$pkg")"); \
for dep in "$store_nm"/*; do \
dep_name=$(basename "$dep"); \
[ -e "$dep_name" ] && ! [ -L "$dep_name" ] && continue; \
[ -L "$dep_name" ] && rm "$dep_name"; \
cp -rL "$dep" "$dep_name" 2>/dev/null || true; \
done; \
fi; \
done \
&& cp -r /app/apps/system/.next/standalone /app/standalone
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/apps/system/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/system/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
Why did it fail 5 times?
Looking back, the fundamental cause was a lack of understanding of pnpm's symlink structure.
| Incident | Incorrect Assumption |
|---|---|
| 1st |
COPYing symlinks will resolve them on the Docker side. |
| 2nd |
cp -rL will resolve them all at once. |
| 3rd | Resolving symlinks at the copy destination will keep relative paths valid. |
| 4th | Copying only the module body will resolve its dependencies. |
| 5th | The standalone path structure will maintain the monorepo structure. |
Each time I proceeded with an assumption, only to realize the failure after actually building and starting Docker.
Considering Alternatives
I've also recorded the alternatives I considered from the start and the reasons they were rejected.
| Method | Reason for Rejection |
|---|---|
node-linker=hoisted (.npmrc) |
Failed when tried previously. Requires outputFileTracingRoot configuration and changes to next.config.js. |
pnpm deploy |
Workspace packages are raw TypeScript (assuming transpilePackages), so path structures shifted and broke the build. |
Copying node_modules entirely |
Image size increases tenfold (~50MB → ~500MB+). |
In the end, the shell script method to manually resolve standalone symlinks was the most reliable. It's not pretty, but it works.
Lessons Learned
-
pnpm symlinks are relative paths. Trying to resolve them at the copy destination results in path shifts. Always resolve them at their original location.
-
pnpm store structure is nested. Package dependencies are placed in the same directory as siblings. Copying only one module body is not enough to resolve dependencies.
-
Next.js standalone path structure is flat. The monorepo structure like
apps/xxx/is not maintained.server.jsis placed at the root of the standalone directory. -
cp -rLis not a magic bullet. It fails when there are dangling symlinks. You must be prepared to handle them individually. -
Docker production builds are hard to verify locally. Since things work with volume mounts in the development environment, symlink issues only surface during the Docker build.
Summary
| Item | Content |
|---|---|
| Problem | pnpm symlinks break in Docker multi-stage builds |
| Cause | Next.js standalone outputs the symlink structure as-is |
| Solution | Resolve with cp -rL including siblings in the pnpm store before COPY
|
| Fix Count | 5 times (#557 → #560 → #561 → #562 → #565) |
| Lesson | Resolve symlinks at the original location, copy dependencies including siblings |
Series Articles
- Part 1: Tackling Unmanageable Complexity with Automation
- Part 2: Automating WebAuthn Tests with CI
- Part 3: Next.js x Go Monorepo Architecture
- Part 4: Multi-tenant Isolation with PostgreSQL RLS
- Part 5: Pitfalls of Multi-portal Authentication
- Part 6: Developing a 200k-line SaaS Solo with Claude Code
- Part 7: Landmines and Solutions in Self-hosted CI/CD
- Part 8: Solo Team Development with Claude Code Agent Team
- Part 9: 5 Struggles with pnpm + Next.js standalone + Docker (This article)
Discussion