A single-stage image that runs npm 2,036 ci and next build in one layer ships the source, the dev dependencies and the build cache to production — hundreds of megabytes of attack surface that never serve a request. Three stages fix it: deps installs from the lockfile, builder compiles, and runner starts from a clean base with only the standalone output of The standalone Output.
ARG NODE_VERSION=24.13.0-slim
FROM node:${NODE_VERSION} AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SITE_NAME
ENV NODE_ENV=production
RUN npm run build
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]Four lines deserve attention. mkdir .next && chown node:node .next exists because the prerender cache is written back into that directory at runtime; skip it and the first revalidation of an ISR page fails with a permission error. USER node drops root using the account the official Node images provide. HOSTNAME=0.0.0.0 is the standalone server's own default, stated so nobody "tidies it up" to 127.0.0.1 and makes the container unreachable. And the ARG belongs in the builder stage: by the time docker run executes, next build has already inlined every NEXT_PUBLIC_* string into the browser bundle. So both stages take their own variables — docker build --build-arg NEXT_PUBLIC_SITE_NAME="Widget Shop" -t widget-web ., then docker run -p 3000:3000 --env-file .env.production widget-web.
Add a .dockerignore containing at least node_modules, .next, .git and .env*; without it the COPY . . in the builder stage drags your local build output and your secrets into an image layer.
The result is a standard container with no Next.js-specific requirements, which is why Kubernetes 5,150 , ECS, Cloud Run 1 , Fly.io 27,739 and a plain docker run accept it unchanged. Docker containerizes the Express 24,430 API the same way; the next subsection runs both plus MongoDB 1,815 together.