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 Images

Reduce Docker image size: multi-stage builds, Alpine images, and layer caching.

DockerMulti-StageOptimizationDevOps

By MinhVo

Introduction

Building efficient Docker images is one of the most impactful optimizations a development team can make. The size of your container images affects build times, deployment speed, storage costs, network bandwidth, and security posture. A bloated image carrying compilers, development tools, and unused libraries wastes resources at every stage of the delivery pipeline and presents a larger surface area for security vulnerabilities.

Docker multi-stage builds provide a clean, built-in solution to this problem. By defining multiple build stages within a single Dockerfile, you can compile your application in a full-featured environment and then copy only the necessary artifacts into a minimal production image. The build tools, intermediate files, and development dependencies stay behind in discarded stages.

Combined with Alpine-based images and intelligent layer caching, multi-stage builds can reduce image sizes by 90% or more while maintaining full build reproducibility. This guide demonstrates these techniques across multiple language ecosystems with practical, production-ready examples.

Optimization and efficiency concept

Understanding Image Optimization: Core Concepts

Docker image size is determined by the layers that compose it. Each Dockerfile instruction (FROM, COPY, RUN, etc.) creates a layer. Layers are additive: deleting a file in a later layer does not reduce the image size because the file still exists in the earlier layer. This is why optimization must happen at the instruction level, not through post-build cleanup.

Why Image Size Matters

Large images create cascading inefficiencies. A 1GB image takes 30-60 seconds to pull on a typical connection, while a 50MB image pulls in under 3 seconds. In a Kubernetes cluster scaling from 3 to 30 pods, that difference means 27 extra pod-seconds of latency during a traffic spike. Multiply this across dozens of deployments per day, and the cumulative waste becomes substantial.

Storage costs compound the problem. Container registries charge for storage, and keeping hundreds of large images for different versions quickly accumulates significant monthly bills. A 100-image registry with 1GB average image size costs meaningfully more than the same registry with 100MB average image size.

Security scanners examine every package in an image. Larger images with more installed packages generate more vulnerability findings, increasing the burden on security teams and extending the time to production for security-critical applications.

The Layer Cache Mechanism

Docker caches each layer after it is built. If a layer's instruction and context have not changed since the last build, Docker reuses the cached layer without executing the instruction. This is why the order of instructions in a Dockerfile matters enormously for build speed.

# Bad: Any source code change invalidates the npm install cache
COPY . .
RUN npm install
 
# Good: npm install only reruns when package.json changes
COPY package.json package-lock.json ./
RUN npm install
COPY . .

Architecture and Design Patterns

The Build-Then-Copy Pattern

The fundamental multi-stage pattern involves a build stage that contains all the tools and dependencies needed to compile or process the application, followed by a production stage that copies only the final artifacts.

# Build stage: full environment
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# /app/dist now contains compiled output
 
# Production stage: minimal runtime
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

The Dependency-First Pattern

Separating dependency installation from source code copying is the most impactful caching optimization for interpreted languages where there is no compilation step.

FROM python:3.12-slim AS deps
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
 
FROM python:3.12-slim AS production
COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=deps /usr/local/bin /usr/local/bin
WORKDIR /app
COPY . .
CMD ["python", "app.py"]

The Static Binary Pattern

For compiled languages that produce static binaries, the most extreme optimization uses an empty scratch base image containing nothing but the compiled binary.

FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o server .
 
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/server"]

Step-by-Step Implementation

Node.js Application Optimization

A typical Node.js application without optimization produces an image of 900MB+. Through multi-stage builds and Alpine images, this can be reduced to under 100MB.

# Multi-stage Node.js Dockerfile
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 tsconfig.json ./
COPY src/ ./src/
RUN npm run build
 
FROM node:20-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
 
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup
 
COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=build --chown=appuser:appgroup /app/dist ./dist
COPY --chown=appuser:appgroup package.json ./
 
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]

Python Application with Native Extensions

Python applications with C extensions (like psycopg2, numpy, or cryptography) require special handling because the build stage needs compilers and development headers.

FROM python:3.12-slim AS builder
WORKDIR /app
 
# Install build dependencies for C extensions
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    gcc \
    g++ \
    libpq-dev \
    libffi-dev \
    && rm -rf /var/lib/apt/lists/*
 
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
 
FROM python:3.12-slim AS production
 
# Install only runtime libraries
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    libpq5 \
    libffi8 \
    && rm -rf /var/lib/apt/lists/*
 
COPY --from=builder /install /usr/local
 
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY . .
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "app:app"]

Go Application with Dependency Caching

FROM golang:1.21-alpine AS builder
WORKDIR /app
 
# Download dependencies (cached unless go.mod/go.sum change)
COPY go.mod go.sum ./
RUN go mod download
 
# Build application
COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
    go build -ldflags="-w -s" -o /server ./cmd/server
 
# Distroless image for security
FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
EXPOSE 8080
CMD ["/server"]

React/Next.js Frontend Optimization

Frontend applications produce static assets that can be served by a lightweight web server, eliminating the Node.js runtime entirely from the production image.

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
 
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
 
FROM nginx:alpine AS production
COPY --from=build /app/out /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Real-World Use Cases and Case Studies

Use Case 1: E-Commerce Platform Migration

An e-commerce platform running 40 microservices on Docker experienced slow deployments and high registry storage costs. Each service image averaged 1.2GB. By implementing multi-stage builds across all services, they reduced the average image size to 180MB. The deployment time for a rolling update dropped from 8 minutes to 2 minutes, and their monthly registry storage bill decreased by 85%.

Use Case 2: Financial Services Compliance

A fintech company needed to pass container security audits for regulatory compliance. Their single-stage Java images contained 600+ installed packages, with 47 flagged as having known vulnerabilities. After implementing multi-stage builds with Alpine base images, the production images contained only 95 packages with zero critical vulnerabilities. The security review that previously took two weeks now completes in two days.

Use Case 3: IoT Firmware Delivery

An IoT company deploying container updates to devices over LTE connections needed to minimize transfer sizes. Multi-stage builds reduced their Go application image from 340MB to 12MB, cutting firmware update times from 45 minutes to 90 seconds on constrained connections. This improvement enabled them to deploy security patches more frequently, improving their overall security posture.

Use Case 4: Multi-Tenant SaaS Platform

A SaaS platform with 200 tenant-specific configurations built separate images for each tenant. Without optimization, the registry consumed 240GB. Multi-stage builds with shared base layers reduced this to 35GB, as the common layers across tenant images were deduplicated. Registry pull times for new pod scheduling dropped by 80%, directly improving tenant isolation and scaling responsiveness.

Best Practices for Production

  1. Pin base image versions precisely: Use node:20.10.0-alpine3.18 instead of node:alpine. This prevents surprise changes when upstream images update and ensures reproducible builds across environments.

  2. Order instructions by change frequency: Copy dependency manifests before source code. Install dependencies before copying application files. This maximizes the portion of the build that uses cache.

  3. Use Alpine images when possible: Alpine Linux images are 5-10x smaller than their Debian equivalents. However, verify that your application does not require glibc (some native Node.js modules need it). If it does, use node:20-slim as a middle ground.

  4. Combine RUN commands to reduce layers: Each RUN instruction creates a layer. Combine related commands and clean up caches in the same instruction to avoid bloating intermediate layers.

  5. Use .dockerignore to exclude unnecessary files: Exclude node_modules, .git, test files, documentation, and CI configuration from the build context. This speeds up context transfer and prevents accidental inclusion of sensitive data.

  6. Leverage BuildKit cache mounts: Use --mount=type=cache for package manager caches. This persists npm/pip/cargo caches across builds without including them in the final image layers.

  7. Test multi-stage builds with --target: Build and test individual stages to catch issues early. Run your test suite against the build stage before producing the production image.

  8. Use health checks in production images: Add HEALTHCHECK instructions so container orchestrators can detect and restart unhealthy containers automatically.

Common Pitfalls and Solutions

PitfallImpactSolution
Copying entire source before dependenciesDependency cache invalidated on every code changeCopy package.json/requirements.txt first, install, then copy source
Using COPY . . too early in buildAll subsequent layers rebuild on any file changeBe specific with COPY paths; copy unchanged files first
Forgetting to clean package manager caches100MB+ of cache data in image layersUse --no-cache-dir (pip), npm cache clean --force, or rm -rf /var/cache/apk/*
Not using named stagesConfusing COPY --from=0 referencesAlways name stages: FROM image AS stage-name
Running as root in productionSecurity risk if container is compromisedAdd USER instruction to switch to non-root user
Using full Debian base unnecessarily5x larger images with more vulnerabilitiesUse Alpine or slim variants; use distroless for compiled binaries
Missing CA certificates in scratch imagesHTTPS connections failCopy /etc/ssl/certs/ca-certificates.crt from builder stage
Build context includes .git directory100MB+ of unnecessary data sent to Docker daemonAdd .git to .dockerignore

Performance Optimization

BuildKit Parallel Execution

BuildKit automatically runs independent stages in parallel. Structure your Dockerfile with independent build stages for frontend and backend components to take advantage of this.

# syntax=docker/dockerfile:1
 
# These stages run 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
FROM node:20-alpine AS production
COPY --from=frontend /app/dist ./static
COPY --from=backend /app/dist ./server
CMD ["node", "server/index.js"]

Layer Caching with BuildKit

# 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"]

Image size comparison across optimization levels for a Node.js API:

Optimization LevelImage SizeBuild Time (cold)Build Time (cached)
No optimization (Debian)1.2GB3 min2.5 min
Alpine base only350MB2 min1.5 min
Multi-stage (Debian)400MB3 min30 sec
Multi-stage (Alpine)150MB2 min20 sec
Multi-stage (Alpine) + BuildKit cache150MB2 min8 sec

Comparison with Alternatives

ApproachSize ReductionComplexityMaintenanceSecurity Impact
Multi-stage builds60-95%LowLowSignificant improvement
Alpine base images70-80%Very lowLowModerate improvement
Distroless images80-90%LowLowExcellent improvement
Manual file cleanup scripts30-50%HighHighMinimal improvement
Cloud Native Buildpacks50-80%Very lowVery lowGood improvement
Nix-based builds70-90%Very highModerateExcellent improvement

Advanced Patterns and Techniques

Conditional Build Stages

ARG NODE_ENV=production
 
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
 
FROM base AS prod-deps
RUN npm ci --omit=dev
 
FROM base AS dev-deps
RUN npm ci
 
FROM ${NODE_ENV:+prod-deps}${NODE_ENV:-dev-deps} 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"]

Test Stage Integration

FROM node:20-alpine AS test
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm test
 
FROM node:20-alpine AS production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "index.js"]
# Run tests first, build production only if tests pass
docker build --target test -t myapp:test . && \
docker build -t myapp:production .

Testing Strategies

# Verify image sizes meet targets
check_image_size() {
  local image=$1
  local max_mb=$2
  local size_mb=$(docker image inspect "$image" --format '{{.Size}}' | awk '{printf "%.0f", $1/1024/1024}')
  
  if [ "$size_mb" -le "$max_mb" ]; then
    echo "PASS: $image is ${size_mb}MB (limit: ${max_mb}MB)"
  else
    echo "FAIL: $image is ${size_mb}MB (limit: ${max_mb}MB)"
  fi
}
 
check_image_size "myapp:production" 150
 
# Verify production image has no build tools
verify_no_build_tools() {
  local image=$1
  for tool in gcc g++ make cmake npm pip; do
    if docker run --rm "$image" which "$tool" 2>/dev/null; then
      echo "FAIL: $tool found in production image"
    else
      echo "PASS: $tool not in production image"
    fi
  done
}
 
verify_no_build_tools "myapp:production"

Future Outlook

Cloud Native Buildpacks are emerging as an alternative to manually writing multi-stage Dockerfiles. Buildpacks automatically detect your application stack and apply the same optimization patterns (dependency separation, minimal base images, non-root execution) without requiring Dockerfile authoring. Heroku, Google Cloud, and VMware all support Buildpacks as a deployment mechanism.

Docker's integration with AI-assisted build optimization is also evolving. Future Docker releases may analyze your Dockerfile and suggest multi-stage transformations, cache optimizations, and base image alternatives automatically.

Conclusion

Multi-stage builds, Alpine images, and layer caching form the foundation of Docker image optimization. Together, they can reduce image sizes by 80-95% while maintaining full build reproducibility and security.

Key takeaways:

  1. Every production Dockerfile should use multi-stage builds. The separation of build and runtime dependencies is the single most impactful optimization available.
  2. Order your Dockerfile instructions strategically. Copy dependency files and install dependencies before copying source code to maximize cache efficiency.
  3. Choose base images based on your requirements. Alpine for size, slim for compatibility, distroless for security, and scratch for static binaries.
  4. Use BuildKit features like cache mounts and parallel stage execution to speed up builds without sacrificing image size.
  5. Measure and track image sizes as part of your CI pipeline. Set size budgets and fail builds that exceed them to prevent gradual bloat over time.