This article was originally published on Saru Blog.
What You Will Learn
- Why pnpm symlinks break in Next.js standalone Docker builds
- When
cp -rLis not enough - Symlink resolution patterns in Docker multi-stage builds
- Lessons from 5 consecutive fix PRs
Background
Saru manages 5 Next.js frontends (Landing / System / Provider / Reseller / Consumer) in a pnpm monorepo. In development, we use volume mounts so Dockerfiles are not needed. But deploying to production or demo environments requires Docker images.
Next.js output: 'standalone' traces only the necessary files into .next/standalone/. Copy this into an Alpine-based runner stage and you get a lightweight image — or so I thought.
Failure 1: MODULE_NOT_FOUND (#557)
Symptom
Error: Cannot find module 'next/dist/compiled/next-server/app-page.runtime.prod.js'
Container crashes immediately on docker run.
Cause
pnpm builds node_modules using 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. Inside the builder stage, the symlink targets exist, so everything works. But when COPY --from=builder brings this to the runner stage, the symlink targets don't exist.
builder (when standalone is created) runner (after COPY)
├── standalone/ ├── standalone/
│ └── node_modules/ │ └── node_modules/
│ └── next → ../../.pnpm/... │ └── next → ../../.pnpm/...
└── node_modules/.pnpm/... ✅ exists └── (nothing) ❌
Attempted Fix
RUN cp -rL /app/apps/system/.next/standalone /app/standalone
cp -rL follows symlinks and copies real files. This should solve it in one command — I thought.
Result: ❌ Failed
Failure 2: Dangling Symlinks (#560)
Symptom
cp: can't stat '/app/apps/system/.next/standalone/node_modules/next': No such file or directory
cp -rL itself fails with an error.
Cause
Some symlinks in the standalone node_modules point to pnpm's virtual store outside the standalone directory. Symlinks pointing to paths not included in standalone become "dangling 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 (real file exists in standalone) ← OK
└── ...
Attempted Fix
"If bulk copy doesn't work, resolve one by one."
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"; \
fi; \
done
Check each symlink individually — if the target exists, copy it; if dangling, skip it.
Result: ❌ Failed (different reason)
Failure 3: Path Mismatch After Copy (#561)
Symptom
The build appeared to succeed, but MODULE_NOT_FOUND again — this time for different modules.
Cause
The Failure 2 approach resolved symlinks after copying to /app/standalone. But pnpm's symlinks use 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/ it points to a different location.
Additionally, some modules only existed in the root .pnpm store, not at the app level.
Attempted Fix
"Don't resolve after copying — resolve at the original location (where paths are valid), 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 symlinks, search the root .pnpm store with find and copy from there.
Result: ❌ Failed (yet another reason)
Failure 4: Transitive Deps and Scoped Packages (#562)
Symptom
Error: Cannot find module 'styled-jsx'
Error: Cannot find module '@swc/helpers'
next itself was found, but styled-jsx and @swc/helpers — dependencies of next — were missing.
Cause
pnpm's store structure has an important characteristic:
node_modules/.pnpm/next@14.2.x/
└── node_modules/
├── next/ ← the package itself
├── styled-jsx/ ← next's dependency (sibling)
├── @swc/
│ └── helpers/ ← scoped package dependency (sibling)
└── react/ ← next's dependency (sibling)
pnpm places a package's dependencies as siblings in the same directory. The Failure 3 approach only copied the module itself, missing the siblings (transitive dependencies).
Furthermore, scoped packages like @swc/helpers live under an @swc/ directory that contains its own symlinks. A simple cp -r breaks these internal symlinks.
Fix
"Don't copy just the module — copy all siblings from 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, locate the module in the pnpm store
- Once found, copy all siblings in that store entry's directory
- Use
cp -rLto also resolve nested symlinks within siblings
Result: ✅ MODULE_NOT_FOUND resolved
Failure 5: Static Asset 404s (#565)
Symptom
The container starts. HTTP requests get responses. But opening the page shows all CSS/JS returning 404.
Cause
# Before
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 — expecting /app/.next/static. But the Dockerfile was copying to /app/apps/system/.next/static, preserving the monorepo path structure.
Fix
# After
COPY --from=builder --chown=nextjs:nodejs /app/apps/system/.next/static ./.next/static
A one-line change. But finding this required checking 404s in the browser DevTools and reading the server.js source.
Result: ✅ Fully working
The 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 all workspace package.json files
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
# Resolve pnpm symlinks (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 5 Failures?
Looking back, the root cause was insufficient understanding of pnpm's symlink structure.
| Failure | Wrong Assumption |
|---|---|
| 1st | Docker will resolve symlinks when COPYing |
| 2nd |
cp -rL will handle everything in one shot |
| 3rd | Relative symlink paths will work after copying to a new location |
| 4th | Copying just the module itself is enough for its dependencies |
| 5th | Standalone preserves the monorepo directory structure |
Each time I proceeded on assumption and only discovered the failure after actually building and running the Docker image.
Alternatives Considered
For reference, here are the alternatives I evaluated and why they were rejected.
| Approach | Why Rejected |
|---|---|
node-linker=hoisted (.npmrc) |
Tried before, failed. Also requires outputFileTracingRoot in next.config.js |
pnpm deploy |
Workspace packages use raw TypeScript (relying on transpilePackages), path structure breaks |
| Copy all node_modules | Image size goes from ~50MB to ~500MB+ |
In the end, manually resolving standalone symlinks via shell script was the most reliable approach. Not elegant, but it works.
Lessons Learned
pnpm symlinks use relative paths. Resolving them after copying to a different location breaks paths. Always resolve at the original location.
pnpm's store structure is nested. A package's dependencies are placed as siblings in the same directory. Copying just one module leaves its dependencies behind.
Next.js standalone output is flat. The monorepo's
apps/xxx/structure is not preserved.server.jsis placed at the standalone root.cp -rLis not universal. It fails on dangling symlinks. Be prepared to handle them individually.Docker production builds are hard to test locally. In development, volume mounts work fine, so symlink issues only surface during Docker build.
Summary
| Item | Detail |
|---|---|
| Problem | pnpm symlinks break in Docker multi-stage builds |
| Cause | Next.js standalone copies symlink structure as-is |
| Solution | cp -rL all siblings from pnpm store entries, then COPY |
| Fix attempts | 5 (#557 → #560 → #561 → #562 → #565) |
| Lesson | Resolve symlinks at source, copy siblings not just the module |
Series Articles
- Part 1: Fighting Unmaintainable Complexity with Automation
- Part 2: Automating WebAuthn Tests in CI
- Part 3: Next.js x Go Monorepo Architecture
- Part 4: Multi-Tenant Isolation with PostgreSQL RLS
- Part 5: Multi-Portal Authentication Pitfalls
- Part 6: Developing a 200K-Line SaaS Alone with Claude Code
- Part 7: Landmines and Solutions in Self-Hosted CI/CD
- Part 8: Turning Solo Development into Team Development with Claude Code Agent Teams
- Part 9: pnpm + Next.js Standalone + Docker: 5 Failures Before Success (this article)
Top comments (0)