MeshWorld India LogoMeshWorld.

Docker Multi-Stage Builds: Smaller Images, Faster Deploys

Listen to ArticleAI Speech
~10 min read narration
100%
Docker Multi-Stage Builds: Smaller Images, Faster Deploys

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.

The Problem: Bloated Production Images

Before Multi-Stage

DOCKERFILE
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

DOCKERFILE
# 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

PLAINTEXT
┌─────────────────┐         ┌─────────────────┐
│   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

DOCKERFILE
# 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

DOCKERFILE
# 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)

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
# 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:

BASH
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:

BASH
# 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:

DOCKERFILE
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

DOCKERFILE
# 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):

DOCKERFILE
# 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

PLAINTEXT
# .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

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
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

BASH
# 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

BASH
# 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

YAML
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

YAML
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

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
# 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

DOCKERFILE
# 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.


Reader Quality Feedback

Did this technical guide help solve your problem?

Suggest Errata ($0)
Cobie
Primary Author

Cobie

DevOps & Site Reliability Engineer. Expert in container orchestration, CI/CD pipeline optimization, and helping teams ship software safely with clean Git workflows.

Explore Author Archive
Compute Fuel & Open Testbed
100% Independent & Verified

Fuel High-Density, Zero-Fluff Engineering Deep-Dives

Every guide on MeshWorld is validated on physical Linux nodes and reproducible testbeds. If this article saved you hours of debugging or unblocked production, consider funding our next cluster run.

Weekly Dispatch

Join MeshWorld Dispatch

Get practical tutorials, system blueprints, and curated AI engineering notes straight to your inbox. No fluff, zero spam.

Zero spam. 1-click unsubscribe anytime.Prefer RSS?
Curated Continuations

Up Next in This Domain.

Browse Full Archive