iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article

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 -rL doesn'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

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

  1. pnpm symlinks are relative paths. Trying to resolve them at the copy destination results in path shifts. Always resolve them at their original location.

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

  3. Next.js standalone path structure is flat. The monorepo structure like apps/xxx/ is not maintained. server.js is placed at the root of the standalone directory.

  4. cp -rL is not a magic bullet. It fails when there are dangling symlinks. You must be prepared to handle them individually.

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

Discussion