DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Docker Multi-Stage Builds: Shrink Your Production Images by 80%

Docker Multi-Stage Builds: Shrink Your Production Images by 80%

A naive Dockerfile for a Next.js app produces a 1.2GB image.
Multi-stage builds get that under 200MB. Here's how.

The Problem with Single-Stage Builds

# Bad — everything ends up in production
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install          # includes devDependencies
COPY . .
RUN npm run build
CMD ["npm", "start"]
# Result: ~1.2GB image with dev tools, build tools, source files
Enter fullscreen mode Exit fullscreen mode

Multi-Stage Build

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

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

# Stage 3: Production image (only what's needed)
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Copy only production deps and build output
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./

# Non-root user for security
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
USER nextjs

EXPOSE 3000
CMD ["npm", "start"]
# Result: ~180MB image
Enter fullscreen mode Exit fullscreen mode

Official Next.js Standalone Output

Next.js has a standalone mode that creates a minimal server:

// next.config.js
module.exports = {
  output: 'standalone',
}
Enter fullscreen mode Exit fullscreen mode
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000

# Standalone output includes everything needed
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
USER nextjs

EXPOSE 3000
CMD ["node", "server.js"]
# Result: ~120MB — no node_modules needed!
Enter fullscreen mode Exit fullscreen mode

Build Arguments for Multiple Environments

FROM node:20-alpine AS builder
ARG NODE_ENV=production
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_STRIPE_KEY

ENV NODE_ENV=$NODE_ENV
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_STRIPE_KEY=$NEXT_PUBLIC_STRIPE_KEY

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
Enter fullscreen mode Exit fullscreen mode
docker build \
  --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
  --build-arg NEXT_PUBLIC_STRIPE_KEY=pk_live_xxx \
  -t myapp:latest .
Enter fullscreen mode Exit fullscreen mode

.dockerignore (Critical)

node_modules
.next
.git
.env*
*.log
README.md
.DS_Store
coverage/
Enter fullscreen mode Exit fullscreen mode

Without this, you're copying 500MB of node_modules into the build context.

Docker Compose for Local Dev

# docker-compose.yml
version: '3.8'
services:
  app:
    build:
      context: .
      target: builder  # use builder stage for hot reload
    volumes:
      - .:/app
      - /app/node_modules  # prevent overwriting container's node_modules
    ports:
      - '3000:3000'
    environment:
      - NODE_ENV=development
    command: npm run dev

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - '5432:5432'

volumes:
  postgres_data:
Enter fullscreen mode Exit fullscreen mode

Layer Caching

Order matters — put rarely-changing layers first:

# Good: package.json changes less than source code
COPY package*.json ./      # cached until package.json changes
RUN npm ci                 # cached until above changes
COPY . .                   # changes every build
RUN npm run build          # must re-run every build

# Bad: source change invalidates npm install cache
COPY . .                   # changes every build
RUN npm install            # re-runs every build!
Enter fullscreen mode Exit fullscreen mode

Size Comparison

Approach Image Size
Single stage (node:20) ~1.2GB
Single stage (alpine) ~400MB
Multi-stage ~180MB
Multi-stage + standalone ~120MB

Smaller images = faster deploys, lower registry costs, smaller attack surface.


The Ship Fast Skill Pack includes a /deploy skill that generates production-ready Dockerfiles, CI/CD pipelines, and Vercel/Fly.io configs for your stack. $49 one-time.

Top comments (0)