M
MeshWorld.
Docker Multi-Stage Builds DevOps Containerization CI/CD Performance Security Best Practices 10 min read

Docker Multi-Stage Builds: Smaller Images, Faster Deploys

Vishnu
By Vishnu

Docker multi-stage builds are the difference between 1GB images that take minutes to deploy and 100MB images that deploy in seconds. They let you use one image for building and a minimal image for production — automatically, in a single Dockerfile.

:::note[TL;DR]

  • Multi-stage builds use multiple FROM statements in one Dockerfile
  • Build in a full image (Node, Python), ship in a minimal image (Alpine, distroless)
  • Copy only compiled artifacts between stages with COPY --from=builder
  • Typical size reduction: 500MB → 50MB (90% smaller)
  • Use .dockerignore to exclude files from build context :::

The Problem: Bloated Production Images

Before Multi-Stage

FROM node:20

WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

EXPOSE 3000
CMD ["npm", "start"]

Result: 1.2GB image containing:

  • Full Node.js runtime
  • 800MB of node_modules (dev dependencies included)
  • Source code and TypeScript files
  • Build tools (webpack, babel, typescript)
  • Git history and test files

All of this gets deployed to production, slowing down pulls, wasting bandwidth, and increasing attack surface.

After Multi-Stage

# Build stage
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./

EXPOSE 3000
CMD ["node", "dist/server.js"]

Result: 120MB image containing only:

  • Minimal Node.js runtime (Alpine)
  • Compiled JavaScript (no source)
  • Production node_modules only
  • No build tools

How Multi-Stage Builds Work

┌─────────────────┐         ┌─────────────────┐
│   Build Stage   │         │  Production     │
│  (Full Image)   │ ──────► │   Stage         │
│                 │  COPY   │ (Minimal Image) │
│ • Build tools   │         │                 │
│ • Source code   │         │ • Compiled app  │
│ • Dev deps      │         │ • Runtime deps    │
│ • Git history   │         │ • No build tools  │
└─────────────────┘         └─────────────────┘
       Dropped                  Deployed

Each FROM starts a new stage. Earlier stages are discarded except for files explicitly copied with COPY --from=stage_name.

Real-World Examples

Node.js Application

# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
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: Production
FROM node:20-alpine AS runner
WORKDIR /app

# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Copy production files
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./public
COPY package.json ./

# Set proper permissions
RUN chown -R nextjs:nodejs /app
USER nextjs

EXPOSE 3000
ENV PORT 3000
ENV NODE_ENV production

CMD ["node", "dist/server.js"]

Python Application

# Stage 1: Builder
FROM python:3.11-slim AS builder

WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    python3-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# Stage 2: Production
FROM python:3.11-alpine

WORKDIR /app

# Copy only installed packages
COPY --from=builder /root/.local /root/.local

# Copy application code
COPY src ./src

# Make sure scripts in .local are usable
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

EXPOSE 8000

CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

Go Application (Ultimate Size Reduction)

# Stage 1: Build
FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .

# Stage 2: Production (Scratch = empty image)
FROM scratch

# Copy CA certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copy binary
COPY --from=builder /app/main .

# Copy timezone data (optional)
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo

EXPOSE 8080

# Run as non-root (even in scratch)
USER 65534:65534

CMD ["./main"]

Result: 15MB image (vs 800MB with full Go image)

React/Vite Frontend

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

# Stage 2: Production (Nginx)
FROM nginx:alpine

# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html

# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

Rust Application

# Stage 1: Build
FROM rust:1.75-slim AS builder

WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src

RUN cargo build --release

# Stage 2: Production
FROM debian:bookworm-slim

# Install runtime dependencies only
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# Copy binary
COPY --from=builder /app/target/release/myapp /usr/local/bin/

# Create non-root user
RUN useradd -m -u 1000 appuser
USER appuser

EXPOSE 8080
CMD ["myapp"]

Java/Spring Boot

# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /app
COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts ./
COPY src ./src

RUN ./gradlew bootJar --no-daemon

# Stage 2: Production
FROM eclipse-temurin:21-jre-alpine

WORKDIR /app

# Create non-root user
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring

# Copy JAR
COPY --from=builder /app/build/libs/*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

Advanced Patterns

Caching Dependencies Separately

# Stage 1: Download dependencies (cached layer)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

# Stage 2: Build (reuses deps layer if package.json unchanged)
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Stage 3: Production
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]

Build Arguments for Flexibility

# Build with different Node versions
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS builder
# ... rest of build

FROM node:${NODE_VERSION}-alpine AS production
# ... production stage

Build with different versions:

docker build --build-arg NODE_VERSION=18 -t myapp:node18 .
docker build --build-arg NODE_VERSION=20 -t myapp:node20 .

Selective Stage Building

Build specific stages only:

# Build just the test stage
docker build --target tester -t myapp:test .

# Build just production
docker build --target production -t myapp:latest .

Dockerfile with test stage:

FROM node:20-alpine AS deps
# ... install dependencies

FROM node:20-alpine AS tester
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm test

FROM node:20-alpine AS builder
# ... build

FROM node:20-alpine AS production
# ... production

Security Scanning Stage

# Production stage
FROM node:20-alpine AS production
# ... production setup

# Security scanning stage (optional, doesn't affect production)
FROM aquasec/trivy:latest AS scanner
COPY --from=production /app /scan
RUN trivy filesystem --exit-code 0 --no-progress /scan

Optimization Strategies

1. Layer Caching

Order Dockerfile commands by change frequency (least → most):

# 1. System dependencies (rarely change)
FROM node:20-alpine
RUN apk add --no-cache curl

# 2. Application dependencies (change occasionally)
COPY package*.json ./
RUN npm ci --only=production

# 3. Application code (changes frequently)
COPY . .
RUN npm run build

2. .dockerignore

# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.env.local
.env.production
.nyc_output
coverage
.nyc_output
.vscode
.idea
*.md
dist
build
.DS_Store

3. Minimal Base Images

LanguageFull ImageMinimal ImageSize
Node.jsnode:20node:20-alpine1GB → 150MB
Pythonpython:3.11python:3.11-alpine900MB → 50MB
Gogolang:1.21scratch + binary800MB → 15MB
Javaeclipse-temurin:21-jdkeclipse-temurin:21-jre-alpine600MB → 200MB

4. Compress Images Further

# Use upx for binary compression (Go, Rust)
FROM golang:1.21-alpine AS builder
# ... build
RUN apk add --no-cache upx
RUN upx --best --lzma main

FROM scratch
COPY --from=builder /app/main .

Size Comparison

ApplicationSingle-StageMulti-StageReduction
Node.js API1.2 GB120 MB90%
Python Django980 MB180 MB82%
Go Microservice850 MB15 MB98%
React Frontend1.5 GB25 MB98%
Java Spring720 MB210 MB71%

Security Benefits

Smaller Attack Surface

# Bad: Full Ubuntu with unnecessary packages
FROM ubuntu:22.04
RUN apt-get install -y python3 nodejs gcc make curl wget vim

# Good: Alpine with only runtime dependencies
FROM python:3.11-alpine
RUN pip install --no-cache-dir -r requirements.txt

No Build Tools in Production

# Build stage has compilers
FROM gcc:latest AS builder
RUN gcc -o app source.c

# Production stage has no compilers
FROM scratch
COPY --from=builder /app/app .

Non-Root User

FROM node:20-alpine

# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001

# Switch to non-root
USER nextjs

Debugging Multi-Stage Builds

Inspect Intermediate Stages

# Build and inspect builder stage
docker build --target builder -t myapp:builder .
docker run -it myapp:builder sh

# Check what's in production image
docker build --target production -t myapp:prod .
docker run -it myapp:prod sh

Dive Tool

# Install dive
wget https://github.com/wagoodman/dive/releases/download/v0.10.0/dive_0.10.0_linux_amd64.deb
sudo dpkg -i dive_0.10.0_linux_amd64.deb

# Analyze image layers
dive myapp:latest

CI/CD Integration

GitHub Actions

name: Build and Push
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: myapp:latest
          target: production
          cache-from: type=gha
          cache-to: type=gha,mode=max

GitLab CI

build:
  image: docker:latest
  services:
    - docker:dind
  script:
    - docker build --target production -t $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:latest
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - /root/.docker

Common Pitfalls

1. Forgetting to Copy All Dependencies

# Wrong: Missing some node_modules
COPY --from=builder /app/node_modules ./node_modules

# Right: Copy all or use production-only install
COPY --from=deps /app/node_modules ./node_modules

2. Using Wrong Stage Names

# Wrong: Typo in stage name
COPY --from=bulder /app/dist ./dist  # builder misspelled

# Right: Match stage name exactly
COPY --from=builder /app/dist ./dist

3. Building on Wrong Architecture

# For multi-arch builds, use BuildKit
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
# ...
FROM node:20-alpine
COPY --from=builder /app/dist ./dist

4. Caching Issues with COPY

# Wrong: COPY invalidates cache too early
COPY . .
RUN npm ci  # Runs on every code change

# Right: Copy package.json first
COPY package*.json ./
RUN npm ci  # Cached unless package.json changes
COPY . .

Summary

  • Multi-stage builds = Multiple FROM statements, one Dockerfile
  • Copy selectively — Use COPY --from=stage_name to move only needed files
  • Minimal final image — Use Alpine, distroless, or scratch
  • Cache smartly — Order by change frequency, use .dockerignore
  • Security built-in — Smaller images, no build tools, non-root users

90% smaller images aren’t just nice to have — they’re faster to pull, cheaper to store, and more secure to run.