Images That Actually Belong in Production

A 2GB Docker image with dev dependencies, a shell, and npminstalled is not a production image. It's a dev environment in a container. Production images should be small, reproducible, run as non-root, and have a minimal attack surface. Multi-stage builds get you there without sacrificing build-time tooling.

๐Ÿค” Sound familiar?
  • Your Docker images are 1GB+ and you're not sure why
  • You run your containers as root because it's the path of least resistance
  • Your Dockerfile has a COPY . . early, invalidating cache on every file change
  • Your compose file for local dev is the same as production โ€” or production has no containers at all

A properly layered multi-stage Dockerfile with a distroless runtime stage reduces image size by 10x and eliminates entire vulnerability classes.

Multi-Stage Builds

Multi-stage builds use multiple FROM instructions in one Dockerfile. Only the final stage ships. Earlier stages can have compilers, dev tools, and test runners โ€” none of it ends up in the production image.


flowchart LR
    deps["deps stage
node:20-alpine
install production deps"] --> runner
    build["build stage
node:20-alpine
compile TypeScript"] --> runner
    runner["runner stage
node:20-alpine
only: node_modules + dist"]

    style runner fill:#16a34a,color:#fff,stroke:#15803d
    style deps fill:#0078D4,color:#fff,stroke:#005a9e
    style build fill:#0078D4,color:#fff,stroke:#005a9e
# Dockerfile โ€” Node.js production multi-stage build
FROM node:20-alpine AS base
WORKDIR /app
# Install pnpm
RUN corepack enable pnpm

# ---- Dependencies stage ----
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
# Install only production deps
RUN pnpm install --frozen-lockfile --prod

# ---- Build stage ----
FROM base AS build
COPY package.json pnpm-lock.yaml ./
# Install all deps including dev (for TypeScript, etc.)
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

# ---- Runner stage (production) ----
FROM node:20-alpine AS runner
WORKDIR /app

# Don't run as root
RUN addgroup --system --gid 1001 nodejs &&     adduser --system --uid 1001 nextjs

# Copy only what's needed from previous stages
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist

USER nextjs

EXPOSE 3000
ENV PORT=3000
ENV NODE_ENV=production

CMD ["node", "dist/server.js"]

Layer Caching Strategy

Docker invalidates cache for a layer when the layer's instruction or its inputs change. Every subsequent layer is also invalidated. Order matters: put frequently changing things last.

# โŒ Bad order โ€” COPY . . early means every file change rebuilds deps
FROM node:20-alpine
WORKDIR /app
COPY . .                     # all files โ€” cache busted on every change
RUN npm install              # re-runs every time
RUN npm run build

# โœ… Good order โ€” copy manifests first, then source
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./   # only changes when deps change
RUN npm ci --omit=dev                    # cached unless manifests change
COPY . .                                 # source changes don't bust dep cache
RUN npm run build

Security Hardening

# Minimal attack surface with distroless (Google)
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

# gcr.io/distroless/nodejs20 โ€” no shell, no package manager, no debug tools
FROM gcr.io/distroless/nodejs20-debian12 AS runner
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]

# Scan image for vulnerabilities in CI:
# docker scout cves myimage:latest
# trivy image myimage:latest

Compose for Local Development

# compose.yml โ€” local dev with hot reload
services:
  app:
    build:
      context: .
      target: build  # Use build stage, not production runner
    ports:
      - '3000:3000'
    volumes:
      - .:/app                       # mount source for hot reload
      - /app/node_modules            # exclude node_modules from mount
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:postgres@db:5432/dev
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: dev
    ports:
      - '5432:5432'
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'

volumes:
  postgres-data:

Production Compose Override

# compose.prod.yml โ€” overrides for production
# Run with: docker compose -f compose.yml -f compose.prod.yml up -d
services:
  app:
    build:
      target: runner  # Use production stage, not build stage
    volumes: []       # No source mounting in production
    environment:
      - NODE_ENV=production
    restart: unless-stopped
    logging:
      driver: 'json-file'
      options:
        max-size: '10m'
        max-file: '3'
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M

.dockerignore

# .dockerignore โ€” keep the build context small
node_modules
.git
.gitignore
*.md
.env
.env.*
dist
coverage
.next
.turbo
**/__tests__
**/*.test.ts
**/*.spec.ts
.eslintrc*
tsconfig*.json
jest.config.*

Pitfalls

Secrets in build args

Never pass secrets (API keys, tokens) via ARG or ENV in a Dockerfile. They end up in the image layers and are visible with docker history. Use Docker BuildKit secrets (RUN --mount=type=secret) or inject at runtime via environment variables.

Running as root

Most base images default to root. Add a USER instruction before CMD. If your app needs to bind to port 80, use a reverse proxy (nginx, traefik) in front and have your app listen on 3000+ instead of running as root.

Using latest tags

FROM node:latest in production means your image is not reproducible. The build that passes CI today might fail tomorrow whenlatest moves. Pin to a specific version: node:20.11.1-alpine3.19.