1. The Problem with Single-Stage Builds
In a naive single-stage Dockerfile, developers install everything needed to compile their source code:
- Build tools (C/C++ compilers, Python, git).
- Heavy development dependencies (TypeScript compiler, Webpack, ESLint, Jest test suites).
- Temporary build caches and package manager tarballs.
Once compiled, those build tools remain permanently embedded inside the final Docker image. A basic Node.js or Next.js app frequently swells to 1.2GB – 1.8GB. This wastes bandwidth on every push, slows down Kubernetes rolling updates, and introduces hundreds of unneeded OS packages with potential security vulnerabilities (CVEs).
2. How Multi-Stage Builds Work
Docker multi-stage builds (introduced in Docker 17.05) allow you to use multiple FROM statements in a single Dockerfile. Each FROM line begins a fresh, isolated build stage using a different base image.
You can compile your assets in a heavy build stage, and selectively copy only the compiled output files into a minimal runtime container using the COPY --from=<stage> directive:
3. Optimizing Docker Layer Caching
Docker caches intermediate image layers based on whether the input files have changed. A frequent mistake is copying the entire application directory before running npm install:
# ❌ Anti-pattern: Invalidates the npm install cache on EVERY code edit COPY . . RUN npm install
By copying only package.json and package-lock.json first, Docker skips npm install entirely unless dependencies actually changed:
# ✅ Best practice: Cache dependencies independently of source code COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build
4. Production-Ready Next.js Multi-Stage Dockerfile
Here is a production-grade multi-stage Dockerfile utilizing Next.js's standalone output mode (configured via output: 'standalone' in next.config.js):
# ── STAGE 1: Install dependencies ── FROM node:20-alpine AS deps WORKDIR /app RUN apk add --no-cache libc6-compat COPY package.json package-lock.json ./ RUN npm ci # ── STAGE 2: Build the application ── FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build # ── STAGE 3: Production runner ── FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 # Security: Never run containers as root! RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs # Copy ONLY compiled standalone bundle and static assets COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" CMD ["node", "server.js"]
5. The Non-Root Security Mandate
By default, Docker processes execute as root (UID 0) inside the container. If a remote code execution vulnerability (RCE) occurs in an application dependency, an attacker who breaks out of container isolation inherits root privileges on the underlying host kernel.
Always create an unprivileged system user and switch to it using the USER instruction (e.g. USER nextjs) before starting the server process.