The crm-app image cherry-copied only socket.io + @socket.io into the runner's node_modules, omitting their transitive closure (engine.io → accepts/ws/cors, ...). server-custom.js is built with esbuild --packages=external, so it require()s those at runtime and crashed with MODULE_NOT_FOUND 'accepts' on first boot. CI reported success because it only builds+pushes — the image runtime was never exercised. Add a hoisted (symlink-free) prod-deps stage and overlay the complete prod dependency tree onto the traced standalone subset. Hoisted layout makes the Docker COPY faithful (pnpm's default symlinked layout dereferences and breaks resolution). Validated locally: accepts, engine.io, socket.io, @socket.io/redis-adapter, ws, cors all resolve. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
58 lines
2.6 KiB
Docker
58 lines
2.6 KiB
Docker
# Stage 1: Install dependencies
|
|
FROM node:20-alpine AS deps
|
|
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
|
WORKDIR /app
|
|
COPY package.json pnpm-lock.yaml ./
|
|
RUN pnpm install --frozen-lockfile --prod=false
|
|
|
|
# Stage 1b: Production dependency tree in a flat (hoisted) node_modules.
|
|
# Hoisted = symlink-free, so a Docker COPY into the runner is faithful
|
|
# (copying pnpm's default symlinked layout dereferences and breaks
|
|
# transitive resolution); complete = the custom socket.io server's deps
|
|
# (engine.io, accepts, ws, ...) all resolve at runtime.
|
|
FROM node:20-alpine AS prod-deps
|
|
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
|
WORKDIR /app
|
|
COPY package.json pnpm-lock.yaml ./
|
|
RUN echo "node-linker=hoisted" > .npmrc && pnpm install --frozen-lockfile --prod
|
|
|
|
# Stage 2: Build the application
|
|
FROM node:20-alpine AS builder
|
|
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
|
WORKDIR /app
|
|
COPY --from=deps /app/node_modules ./node_modules
|
|
COPY . .
|
|
# NODE_ENV=production in the builder makes `next build` and any code
|
|
# branching on isProd deterministic (build-auditor M9). Without this,
|
|
# CSP and other prod-only paths would compile under whatever NODE_ENV
|
|
# the host carried in.
|
|
ENV NODE_ENV=production
|
|
ENV NEXT_TELEMETRY_DISABLED=1
|
|
ENV SKIP_ENV_VALIDATION=1
|
|
RUN pnpm build
|
|
|
|
# Stage 3: Production runner
|
|
FROM node:20-alpine AS runner
|
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
|
WORKDIR /app
|
|
ENV NODE_ENV=production
|
|
ENV NEXT_TELEMETRY_DISABLED=1
|
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
|
COPY --from=builder --chown=nextjs:nodejs /app/dist/server.js ./server-custom.js
|
|
# The custom socket.io server (server-custom.js, built with esbuild
|
|
# --packages=external) resolves socket.io and its FULL transitive closure
|
|
# (engine.io → accepts/ws/cors, @socket.io/redis-adapter, ...) from
|
|
# node_modules at runtime. The Next tracer omits these from
|
|
# .next/standalone because no Next route imports the socket server, and
|
|
# cherry-copying just socket.io/ leaves its deps unresolved
|
|
# (MODULE_NOT_FOUND 'accepts'). Overlay the complete prod dependency tree
|
|
# (flat/hoisted layout → symlink-safe copy) on top of the traced subset.
|
|
COPY --from=prod-deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
|
USER nextjs
|
|
EXPOSE 3000
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
|
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT:-3000}/api/health || exit 1
|
|
CMD ["node", "server-custom.js"]
|