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 (node in the official image)
  • Copy package*.json before source to exploit layer caching
  • Use npm ci --omit=dev in 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=standalone to next.config.mjs to enable the server.js entry point
  • Use .dockerignore to exclude node_modules, .next, .env*
  • Tag images with git rev-parse --short HEAD for traceability