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
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
Official Next.js Standalone Output
Next.js has a standalone mode that creates a minimal server:
// next.config.js
module.exports = {
output: 'standalone',
}
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!
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
docker build \
--build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
--build-arg NEXT_PUBLIC_STRIPE_KEY=pk_live_xxx \
-t myapp:latest .
.dockerignore (Critical)
node_modules
.next
.git
.env*
*.log
README.md
.DS_Store
coverage/
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:
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!
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)