MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Docker Multi-Stage Builds: Optimizing Image Size

Reduce Docker image sizes with multi-stage builds: build dependencies vs runtime.

DockerMulti-StageOptimizationDevOps

By MinhVo

Introduction

Container image size directly impacts every stage of the software delivery pipeline. Large images take longer to build, consume more bandwidth during registry pushes and pulls, increase storage costs, and expand the attack surface by including unnecessary software packages. A Node.js application that produces a 1.2GB Docker image wastes significant time and money compared to one that achieves the same functionality in 120MB.

Docker multi-stage builds solve this fundamental problem by separating the build environment from the runtime environment within a single Dockerfile. Build tools, compilers, package managers, and source code that are only needed during the build phase are excluded from the final image. The result is a lean production image containing only the compiled application and its runtime dependencies.

This technique was introduced in Docker 17.05 and has since become the standard approach for building optimized container images. It replaces fragile workarounds like maintaining separate build and production Dockerfiles, using external build scripts to strip files, or relying on Alpine-based images alone for size reduction.

This guide covers multi-stage build patterns for every major language ecosystem, advanced optimization techniques, and real-world strategies for achieving dramatic image size reductions without sacrificing build reproducibility or developer experience.

Container optimization concept

Understanding Multi-Stage Builds: Core Concepts

A multi-stage Dockerfile contains multiple FROM instructions, each beginning a new build stage. Each stage starts from a base image and can copy files from previous stages using the COPY --from=<stage> directive. Only the final stage determines what ends up in the produced image unless you explicitly target a different stage.

The Problem Multi-Stage Builds Solve

Consider a compiled language like Go. Building a Go application requires the Go compiler, standard library source code, and potentially hundreds of megabytes of module dependencies. The compiled binary, however, is a single statically-linked executable that runs without any of these build dependencies. Before multi-stage builds, developers either included the entire Go toolchain in the production image (enormous waste) or maintained separate Dockerfiles and build scripts (fragile and error-prone).

# Without multi-stage: includes 800MB+ of Go toolchain
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
# Final image size: ~900MB
 
# With multi-stage: only the compiled binary
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
 
FROM scratch
COPY --from=builder /app/server /server
CMD ["/server"]
# Final image size: ~15MB

Stage Naming and Targeting

Each stage can be named using the AS keyword. Names make it clear which stage you are referencing in COPY --from directives and allow you to build specific stages using docker build --target.

# Build only the test stage (for running tests)
docker build --target test -t myapp:test .
 
# Build only the builder stage (for extracting artifacts)
docker build --target builder -t myapp:builder .
 
# Build the full production image (default)
docker build -t myapp:latest .

Build Cache Behavior Across Stages

Each stage has its own build cache. If nothing changes in a stage, Docker reuses the cached layer. This is particularly powerful for dependency installation: if package.json has not changed, the dependency installation stage uses the cache even if application source code has changed.

Architecture and Design Patterns

Dependency Separation Pattern

The most common multi-stage pattern separates dependency installation from application code copying. This ensures that expensive operations like npm install, pip install, or go mod download are cached independently from source code changes.

# Node.js dependency separation
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
 
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
CMD ["node", "dist/index.js"]

Build Tool Isolation Pattern

Separate build tools into their own stage, then copy only the build artifacts. This is essential for languages that produce compiled output.

# TypeScript compilation with isolated build tools
FROM node:20-alpine AS build-tools
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npx tsc --outDir dist
 
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=build-tools /app/dist ./dist
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/index.js"]

Static Asset Processing Pattern

For web applications, static assets (CSS, JavaScript bundles, images) often need processing (minification, compilation, optimization) that requires development tools not needed at runtime.

# Frontend build with asset processing
FROM node:20-alpine AS frontend-build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# Output: /app/dist/ with optimized static files
 
FROM nginx:alpine AS production
COPY --from=frontend-build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Step-by-Step Implementation

Python Application Multi-Stage Build

Python applications benefit significantly from multi-stage builds because the installed packages often include compiled C extensions and development headers that are not needed at runtime.

# Stage 1: Build dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
 
# Install build dependencies for compiled packages
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*
 
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
 
# Stage 2: Production image
FROM python:3.12-slim AS production
 
# Install only runtime dependencies
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*
 
# Copy installed Python packages from builder
COPY --from=builder /install /usr/local
 
# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
 
WORKDIR /app
COPY . .
 
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:create_app()"]

Size comparison for a Python Flask application with PostgreSQL and Redis dependencies:

  • Single-stage build: ~1.1GB
  • Multi-stage with slim base: ~280MB
  • Multi-stage with Alpine base: ~180MB

Rust Application Multi-Stage Build

Rust compilation is notoriously slow and produces large build artifacts. Multi-stage builds are practically mandatory for Rust containers.

# Stage 1: Build the Rust application
FROM rust:1.74 AS builder
WORKDIR /app
 
# Cache dependencies
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && \
    cargo build --release && \
    rm -rf src
 
# Build actual application
COPY src/ ./src/
RUN touch src/main.rs && cargo build --release
 
# Stage 2: Minimal production image
FROM debian:bookworm-slim AS production
 
RUN apt-get update && \
    apt-get install -y --no-install-recommends ca-certificates && \
    rm -rf /var/lib/apt/lists/* && \
    groupadd -r appuser && \
    useradd -r -g appuser appuser
 
COPY --from=builder /app/target/release/myapp /usr/local/bin/myapp
 
USER appuser
CMD ["myapp"]

The Rust dependency caching trick (creating a dummy main.rs) is critical. Without it, any change to src/main.rs invalidates the dependency download layer, forcing a full recompilation of all dependencies on every build.

Java Application Multi-Stage Build

Java applications produce JAR files that require only a JRE to run. The build stage needs a full JDK, but the production image only needs the runtime.

# Stage 1: Build with Maven
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src/ ./src/
RUN mvn package -DskipTests
 
# Stage 2: Run with JRE only
FROM eclipse-temurin:21-jre-alpine AS production
 
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
 
COPY --from=builder /app/target/app.jar /app/app.jar
 
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Size comparison for a Spring Boot application:

  • Maven + JDK build: ~850MB
  • JRE-only production image: ~280MB
  • JRE Alpine production image: ~180MB

Go Application Multi-Stage Build

Go produces statically linked binaries, enabling the most dramatic size reductions. The final image can be built from scratch (an empty base image) or distroless for maximum minimalism.

# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
 
# Cache module downloads
COPY go.mod go.sum ./
RUN go mod download
 
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-w -s" -o /server ./cmd/server
 
# Stage 2: Scratch (empty image)
FROM scratch AS production
 
# Copy CA certificates for HTTPS support
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
 
# Copy the compiled binary
COPY --from=builder /server /server
 
EXPOSE 8080
ENTRYPOINT ["/server"]

Size comparison for a Go HTTP server:

  • golang:1.21 base: ~850MB
  • golang:1.21-alpine base: ~300MB
  • Multi-stage with scratch: ~12MB
  • Multi-stage with distroless: ~20MB

.NET Application Multi-Stage Build

# Stage 1: Restore dependencies
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
 
# Stage 2: Build and publish
FROM restore AS build
COPY . .
RUN dotnet publish -c Release -o /app --no-restore
 
# Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS production
WORKDIR /app
COPY --from=build /app .
 
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]

Real-World Use Cases and Case Studies

Use Case 1: CI/CD Pipeline Optimization

A DevOps team managing 50 microservices reduced their CI/CD pipeline times by 40% through multi-stage builds. The key insight was separating dependency caching into its own stage and using Docker BuildKit's --cache-from flag to pull cached dependency layers from a registry. Even when source code changed on every commit, the dependency installation stage was almost always cached, saving 2-5 minutes per build.

Their build script used --mount=type=cache for even faster rebuilds:

# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

Use Case 2: Security Compliance

A healthcare company needed to pass container security scans for HIPAA compliance. Their single-stage Python images contained GCC, development headers, and test frameworks that triggered dozens of vulnerability findings. Multi-stage builds reduced the number of installed packages from 400+ to approximately 80, eliminating most vulnerability findings and reducing their security review cycle from weeks to days.

Use Case 3: Edge Deployment

An IoT company deploying container updates over cellular connections needed to minimize image sizes. Multi-stage builds reduced their Go application image from 350MB to 15MB, cutting deployment time from 20 minutes to under one minute on constrained connections. The scratch base image eliminated the entire operating system layer, leaving only the compiled binary and its embedded assets.

Use Case 4: Development Workflow Improvement

A frontend team used multi-stage builds to create a single Dockerfile that served both development and production needs. The development target included hot-reload tools and source maps, while the production target contained only the optimized static assets served by Nginx.

FROM node:20-alpine AS development
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
 
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html

Best Practices for Production

  1. Order instructions by change frequency: Place instructions that change least often (dependency installation) before instructions that change most often (source code copying). This maximizes cache hits and minimizes rebuild time.

  2. Use specific base image tags: Pin base images to exact versions (node:20.10.0-alpine3.18) rather than floating tags (node:alpine). This prevents unexpected changes when base images are updated and ensures reproducible builds.

  3. Clean up in the same layer: If you must install and remove packages in a single stage, do it in the same RUN instruction. Each layer persists its changes, so a RUN apt-get install followed by a separate RUN apt-get clean still includes the installed packages in the first layer.

  4. Use .dockerignore aggressively: Exclude test files, documentation, CI configuration, and development dependencies from the build context. This reduces context transfer time and prevents accidentally including sensitive files.

  5. Leverage BuildKit features: Enable BuildKit (DOCKER_BUILDKIT=1) for parallel stage execution, secret mounting (--mount=type=secret), and cache mounts (--mount=type=cache). These features significantly improve build performance and security.

  6. Choose the right base image: alpine for smallest size, slim for better compatibility, distroless for security-critical deployments, and scratch for static binaries with no OS dependencies. Each choice involves trade-offs between size, compatibility, and debugging capability.

  7. Test all build stages: Use docker build --target <stage> to run tests against intermediate stages. This catches build failures early and ensures each stage produces the expected output.

  8. Document stage purposes: Add comments explaining what each stage does and why. Multi-stage Dockerfiles are more complex than single-stage ones, and clear documentation prevents confusion during maintenance.

Common Pitfalls and Solutions

PitfallImpactSolution
Copying files from wrong stageMissing files or wrong versions in production imageUse named stages (AS builder) and reference them explicitly in COPY --from
Not cleaning package manager cache100MB+ of unnecessary cache data in imageAdd --no-cache-dir (pip), npm cache clean --force (npm), or rm -rf /var/cache/apk/* (Alpine)
Installing dev dependencies in production stageLarger image with unnecessary packagesUse npm ci --omit=dev, pip install --no-cache-dir -r requirements.txt (production only)
Forgetting to copy CA certificatesHTTPS connections fail in scratch/distroless imagesCOPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
Source code change invalidates dependency cacheEvery build downloads all dependenciesCopy dependency files (package.json, go.mod) separately before copying source code
Using full Debian/Ubuntu base unnecessarilyImages 5x larger than Alpine equivalentSwitch to Alpine or slim variants unless your application requires glibc or specific system libraries
Not using BuildKit cache mountsSlow npm/pip/cargo installs on every buildUse --mount=type=cache,target=/root/.npm in RUN instructions

Performance Optimization

BuildKit Cache Mounts

BuildKit's cache mount feature persists package manager caches across builds without including them in the image layers. This provides the speed benefits of caching without the size cost.

# syntax=docker/dockerfile:1
 
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev
 
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "index.js"]
# Python with pip cache mount
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --prefix=/install -r requirements.txt
 
FROM python:3.12-slim AS production
COPY --from=builder /install /usr/local
COPY . .
CMD ["python", "app.py"]

Parallel Build Stage Execution

BuildKit automatically executes independent stages in parallel. Structure your Dockerfile so that stages without dependencies on each other can build simultaneously.

# Frontend and backend build in parallel
FROM node:20-alpine AS frontend
WORKDIR /app
COPY frontend/ .
RUN npm ci && npm run build
 
FROM node:20-alpine AS backend
WORKDIR /app
COPY backend/ .
RUN npm ci && npm run build
 
# This stage waits for both to complete
FROM node:20-alpine AS production
COPY --from=frontend /app/dist ./static
COPY --from=backend /app/dist ./server
CMD ["node", "server/index.js"]

Image Size Comparison Table

Application TypeSingle-StageMulti-Stage (Debian)Multi-Stage (Alpine)Multi-Stage (Distroless/Scratch)
Node.js Express API950MB350MB150MB120MB
Python Flask API1.1GB280MB180MBN/A
Go HTTP server850MB200MB80MB12MB
Rust web server1.5GB300MB150MB25MB
Java Spring Boot850MB350MB280MB180MB
.NET Web API700MB250MB180MBN/A

Comparison with Alternatives

ApproachImage SizeBuild ComplexityCache EfficiencySecurityMaintenance
Multi-stage buildsExcellentLowHighHighLow
Separate build/prod DockerfilesGoodHighLowHighHigh
Script-based file cleanupModerateModerateModerateModerateHigh
Alpine-only (no multi-stage)ModerateLowModerateModerateLow
Distroless base imagesExcellentLowHighExcellentLow
Buildpacks (Cloud Native)GoodVery LowHighHighVery Low

Advanced Patterns and Techniques

Conditional Multi-Stage Builds

# syntax=docker/dockerfile:1
 
ARG BUILD_ENV=production
 
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
 
FROM base AS deps-production
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev
 
FROM base AS deps-development
RUN --mount=type=cache,target=/root/.npm \
    npm ci
 
FROM deps-${BUILD_ENV} AS final-deps
 
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=final-deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "index.js"]

Extracting Artifacts from Multi-Stage Builds

# Extract the compiled binary without running the container
docker build --target builder -o ./output .
# Output directory contains the built artifacts
 
# Copy specific files from a build stage
docker create --name builder myapp:builder
docker cp builder:/app/dist ./dist
docker rm builder

Secret Mounting in Multi-Stage Builds

# syntax=docker/dockerfile:1
 
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
 
# Mount npm token for private registry access
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) \
    npm ci
 
RUN npm run build
# Build with secret
docker build --secret id=npm_token,src=$HOME/.npmrc -t myapp:latest .

Testing Strategies

# Test each stage independently
docker build --target deps -t myapp:deps .
docker build --target build -t myapp:build .
docker build --target production -t myapp:production .
 
# Verify image sizes
docker images myapp --format "{{.Tag}}: {{.Size}}"
 
# Test that production image has required files
docker run --rm myapp:production ls -la /app/
 
# Verify no build tools in production
docker run --rm myapp:production which gcc && echo "FAIL: gcc found" || echo "PASS: no gcc"
docker run --rm myapp:production which python3 && echo "PASS: python found" || echo "FAIL: no python"

Future Outlook

Docker's build system continues to evolve with features that enhance multi-stage builds. BuildKit's ongoing development includes better remote caching, distributed builds, and integration with cloud build services. The OCI image specification is expanding to support more artifact types, enabling multi-stage builds for non-container workloads like WebAssembly modules and machine learning models.

Cloud-native buildpacks represent an alternative approach that automates the multi-stage pattern. Tools like Paketo and Google Cloud Buildpacks detect your application stack and produce optimized images automatically, applying the same dependency separation principles that multi-stage Dockerfiles encode manually.

Conclusion

Multi-stage builds are the single most impactful optimization you can apply to your Docker images. They separate build-time dependencies from runtime dependencies within a single, reproducible Dockerfile, producing images that are smaller, more secure, and faster to deploy.

Key takeaways:

  1. Every production Dockerfile should use multi-stage builds. The complexity added is minimal compared to the benefits in image size, security, and build speed.
  2. Separate dependency installation from source code copying. This maximizes build cache efficiency and dramatically reduces rebuild times during development.
  3. Choose base images intentionally. Alpine for size, slim for compatibility, distroless for security, and scratch for static binaries.
  4. Leverage BuildKit features for cache mounts, parallel stage execution, and secret mounting. These features make multi-stage builds faster and more secure.
  5. Measure your image sizes before and after optimization. Use docker images and docker history to understand where space is being consumed and track improvements over time.