Back to Blog
DockerDevOpsContainersOptimization

Docker Multi-Stage Builds Explained

Optimize Docker images with multi-stage builds. From build stages to caching to production-ready images.

B
Bootspring Team
Engineering
August 20, 2022
5 min read

Multi-stage builds create smaller, more secure Docker images. Here's how to use them effectively.

Why Multi-Stage Builds#

Single-stage problems: - Build tools in production image - Large image sizes - Security vulnerabilities from dev dependencies - Slow deployments Multi-stage benefits: - Separate build and runtime - Smaller final images - No build tools in production - Better security posture

Basic Multi-Stage Build#

1# Stage 1: Build 2FROM node:20-alpine AS builder 3 4WORKDIR /app 5 6# Install dependencies 7COPY package*.json ./ 8RUN npm ci 9 10# Build application 11COPY . . 12RUN npm run build 13 14# Stage 2: Production 15FROM node:20-alpine AS production 16 17WORKDIR /app 18 19# Copy only production dependencies 20COPY package*.json ./ 21RUN npm ci --only=production 22 23# Copy built files from builder 24COPY --from=builder /app/dist ./dist 25 26# Run as non-root user 27USER node 28 29EXPOSE 3000 30CMD ["node", "dist/index.js"]

TypeScript Application#

1# Build stage 2FROM node:20-alpine AS builder 3 4WORKDIR /app 5 6COPY package*.json ./ 7COPY tsconfig.json ./ 8RUN npm ci 9 10COPY src ./src 11RUN npm run build 12 13# Production stage 14FROM node:20-alpine 15 16WORKDIR /app 17 18# Install production dependencies only 19COPY package*.json ./ 20RUN npm ci --only=production && npm cache clean --force 21 22# Copy compiled JavaScript 23COPY --from=builder /app/dist ./dist 24 25USER node 26EXPOSE 3000 27CMD ["node", "dist/index.js"]

Next.js Application#

1# Dependencies stage 2FROM node:20-alpine AS deps 3WORKDIR /app 4COPY package*.json ./ 5RUN npm ci 6 7# Builder stage 8FROM node:20-alpine AS builder 9WORKDIR /app 10COPY --from=deps /app/node_modules ./node_modules 11COPY . . 12 13ENV NEXT_TELEMETRY_DISABLED 1 14RUN npm run build 15 16# Production stage 17FROM node:20-alpine AS runner 18WORKDIR /app 19 20ENV NODE_ENV production 21ENV NEXT_TELEMETRY_DISABLED 1 22 23RUN addgroup --system --gid 1001 nodejs 24RUN adduser --system --uid 1001 nextjs 25 26COPY --from=builder /app/public ./public 27COPY --from=builder /app/.next/standalone ./ 28COPY --from=builder /app/.next/static ./.next/static 29 30USER nextjs 31EXPOSE 3000 32ENV PORT 3000 33 34CMD ["node", "server.js"]

Go Application#

1# Build stage 2FROM golang:1.21-alpine AS builder 3 4WORKDIR /app 5 6# Download dependencies 7COPY go.mod go.sum ./ 8RUN go mod download 9 10# Build binary 11COPY . . 12RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server 13 14# Production stage - minimal image 15FROM scratch 16 17COPY --from=builder /app/server /server 18COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 19 20EXPOSE 8080 21ENTRYPOINT ["/server"]

Python Application#

1# Build stage 2FROM python:3.11-slim AS builder 3 4WORKDIR /app 5 6# Install build dependencies 7RUN apt-get update && apt-get install -y --no-install-recommends \ 8 build-essential \ 9 && rm -rf /var/lib/apt/lists/* 10 11# Create virtual environment 12RUN python -m venv /opt/venv 13ENV PATH="/opt/venv/bin:$PATH" 14 15# Install dependencies 16COPY requirements.txt . 17RUN pip install --no-cache-dir -r requirements.txt 18 19# Production stage 20FROM python:3.11-slim 21 22WORKDIR /app 23 24# Copy virtual environment 25COPY --from=builder /opt/venv /opt/venv 26ENV PATH="/opt/venv/bin:$PATH" 27 28# Copy application 29COPY . . 30 31USER nobody 32EXPOSE 8000 33CMD ["gunicorn", "app:app", "-b", "0.0.0.0:8000"]

Optimizing Layer Caching#

1FROM node:20-alpine AS builder 2 3WORKDIR /app 4 5# 1. Copy dependency files first (changes less often) 6COPY package*.json ./ 7 8# 2. Install dependencies (cached if package.json unchanged) 9RUN npm ci 10 11# 3. Copy source code (changes most often) 12COPY tsconfig.json ./ 13COPY src ./src 14 15# 4. Build 16RUN npm run build 17 18FROM node:20-alpine 19 20WORKDIR /app 21 22COPY package*.json ./ 23RUN npm ci --only=production 24 25COPY --from=builder /app/dist ./dist 26 27CMD ["node", "dist/index.js"]

Using Build Arguments#

1FROM node:20-alpine AS builder 2 3ARG NODE_ENV=production 4ARG API_URL 5 6WORKDIR /app 7 8COPY package*.json ./ 9RUN npm ci 10 11COPY . . 12 13# Use build args during build 14ENV NEXT_PUBLIC_API_URL=$API_URL 15RUN npm run build 16 17FROM node:20-alpine 18 19WORKDIR /app 20 21COPY --from=builder /app/.next/standalone ./ 22COPY --from=builder /app/public ./public 23COPY --from=builder /app/.next/static ./.next/static 24 25CMD ["node", "server.js"]
# Build with arguments docker build \ --build-arg API_URL=https://api.example.com \ -t myapp:latest .

Testing Stage#

1FROM node:20-alpine AS base 2WORKDIR /app 3COPY package*.json ./ 4RUN npm ci 5 6FROM base AS test 7COPY . . 8RUN npm run lint 9RUN npm run test 10 11FROM base AS builder 12COPY . . 13RUN npm run build 14 15FROM node:20-alpine AS production 16WORKDIR /app 17COPY --from=builder /app/dist ./dist 18COPY --from=builder /app/package*.json ./ 19RUN npm ci --only=production 20CMD ["node", "dist/index.js"]
# Run tests without building production image docker build --target test . # Build production image (includes test stage) docker build --target production -t myapp:latest .

Size Comparison#

1# Before: Single stage (~1.2GB) 2FROM node:20 3WORKDIR /app 4COPY . . 5RUN npm install 6RUN npm run build 7CMD ["node", "dist/index.js"] 8 9# After: Multi-stage (~150MB) 10FROM node:20-alpine AS builder 11WORKDIR /app 12COPY package*.json ./ 13RUN npm ci 14COPY . . 15RUN npm run build 16 17FROM node:20-alpine 18WORKDIR /app 19COPY --from=builder /app/dist ./dist 20COPY package*.json ./ 21RUN npm ci --only=production 22CMD ["node", "dist/index.js"]

Best Practices#

Structure: ✓ Use specific base image tags ✓ Order layers by change frequency ✓ Minimize layer count ✓ Use .dockerignore Security: ✓ Run as non-root user ✓ Don't include secrets in image ✓ Scan images for vulnerabilities ✓ Use minimal base images Performance: ✓ Leverage build cache ✓ Use --mount=type=cache for package managers ✓ Parallelize independent stages ✓ Clean up in same layer as install

Conclusion#

Multi-stage builds separate build-time dependencies from runtime, resulting in smaller, more secure images. Structure Dockerfiles to maximize cache hits, use Alpine or distroless base images, and always run as non-root in production.

Share this article

Help spread the word about Bootspring