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.
- 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 buildSecurity 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:latestCompose 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.

