DEV Community

ko-chan
ko-chan

Posted on • Originally published at ko-chan.github.io

pnpm + Next.js Standalone + Docker: 5 Failures Before Success [Part 9]

This article was originally published on Saru Blog.


What You Will Learn

  • Why pnpm symlinks break in Next.js standalone Docker builds
  • When cp -rL is 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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) ❌
Enter fullscreen mode Exit fullscreen mode

Attempted Fix

RUN cp -rL /app/apps/system/.next/standalone /app/standalone
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
└── ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 -rL to 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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

  1. pnpm symlinks use relative paths. Resolving them after copying to a different location breaks paths. Always resolve at the original location.

  2. 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.

  3. Next.js standalone output is flat. The monorepo's apps/xxx/ structure is not preserved. server.js is placed at the standalone root.

  4. cp -rL is not universal. It fails on dangling symlinks. Be prepared to handle them individually.

  5. 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

Top comments (0)