Overview
Multi-stage builds are the single most impactful Dockerfile optimisation. By separating the build stage from the runtime stage you avoid shipping compilers, test frameworks and source maps to production.
Key principles
- Run as a non-root user (
nodein the official image) - Copy
package*.jsonbefore source to exploit layer caching - Use
npm ci --omit=devin production to exclude devDependencies - Pin to a specific Node.js minor version for reproducibility
Dockerfile
# syntax=docker/dockerfile:1
# ── Stage 1: build ───────────────────────────────────────────
FROM node:20.14-alpine AS builder
WORKDIR /app
# Copy manifests first — maximise layer cache hits
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# ── Stage 2: production runtime ──────────────────────────────
FROM node:20.14-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000
# Security: run as non-root
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
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
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3000/api/healthz || exit 1
CMD ["node", "server.js"]
docker-compose.yml (production)
services:
app:
image: my-app:latest
build:
context: .
target: runner
restart: unless-stopped
ports:
- "3000:3000"
environment:
DATABASE_URL: ${DATABASE_URL}
deploy:
resources:
limits:
memory: 512m
Tips
- Add
--output=standalonetonext.config.mjsto enable theserver.jsentry point - Use
.dockerignoreto excludenode_modules,.next,.env* - Tag images with
git rev-parse --short HEADfor traceability