DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Docker Multi-Stage Builds: Slash Your Image Size by 90%

The Problem with Naive Docker Images

# Don't do this
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
Enter fullscreen mode Exit fullscreen mode

This image weighs in around 1.2GB. It includes:

  • Full Node.js runtime + dev tools
  • All devDependencies (TypeScript, ESLint, Jest, etc.)
  • Source files
  • Build artifacts
  • npm cache

Your production server needs exactly none of those except the compiled output and production dependencies.

Multi-Stage Builds

# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Runner (final image)
FROM node:20-alpine AS runner
WORKDIR /app

# Non-root user for security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Copy only what's needed
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json

USER nextjs
EXPOSE 3000
CMD ["node", "dist/server.js"]
Enter fullscreen mode Exit fullscreen mode

Result: ~120MB instead of 1.2GB. 90% reduction.

How It Works

Each FROM instruction creates a new stage. The final image only contains files explicitly copied from previous stages via COPY --from=<stage>.

Docker throws away intermediate stages—they exist only to produce artifacts.

Real-World Examples

Next.js App

FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

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
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

Go Binary

# Builder: full Go toolchain
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server

# Runner: nearly nothing
FROM scratch AS runner
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
CMD ["/server"]
Enter fullscreen mode Exit fullscreen mode

FROM scratch = zero base image. Final image is just your binary. Typically 10-20MB.

Python FastAPI

FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt --output requirements.txt --without-hashes

FROM python:3.12-slim AS runner
WORKDIR /app
COPY --from=builder /app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Enter fullscreen mode Exit fullscreen mode

Layer Caching Optimization

# Bad: invalidates cache on ANY file change
COPY . .
RUN npm install

# Good: npm install only re-runs when package.json changes
COPY package*.json ./
RUN npm install
COPY . .
Enter fullscreen mode Exit fullscreen mode

Docker caches each layer. If the input files haven't changed, it reuses the cached layer. Put slow, infrequent operations first.

Build Arguments for Dynamic Stages

FROM node:20-alpine AS base

FROM base AS development
RUN npm install
CMD ["npm", "run", "dev"]

FROM base AS production
RUN npm ci --only=production && npm run build
CMD ["node", "dist/server.js"]
Enter fullscreen mode Exit fullscreen mode
# Build specific target
docker build --target development -t myapp:dev .
docker build --target production -t myapp:prod .
Enter fullscreen mode Exit fullscreen mode

Size Comparison

# Check your image sizes
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"

# Dive tool for layer analysis
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock \
  wagoodman/dive:latest myapp:prod
Enter fullscreen mode Exit fullscreen mode

Typical results after multi-stage:

  • Node.js app: 1.2GB → 150MB
  • Go service: 800MB → 15MB
  • Python app: 900MB → 200MB

Smaller images = faster pulls = faster deploys = lower storage costs.


Building production microservices? The Whoff Agents AI SaaS Starter Kit includes optimized Dockerfiles for Node.js and Next.js apps.

Top comments (0)