Ops Notes

Docker Multi-Stage Build Optimization Guide: Slash 2GB Images Down to 150MB

Developer Tools Visualization

Stop Using Single-Stage Builds. Seriously.

Last week, one of our Go microservices had a 1.2GB image. I stared at that number for a solid minute. How the hell does a 2000-line HTTP API end up larger than a full Ubuntu desktop image? I checked the Dockerfile — classic single-stage build: FROM golang:1.21, then crammed the entire Go toolchain, source code, and dependencies into the final image.

This problem is way too common. I’ve seen teams ship Node.js apps in 2GB+ images because they used FROM node:18 as the base and dragged the entire node_modules into production. Worse, I’ve seen people leave build tools, test frameworks, and even linters in the final image — that’s not just wasted space, it’s a security nightmare.

Multi-stage builds aren’t new — Docker 17.05 introduced them. But honestly, I’ve seen too many people use them as “pseudo multi-stage” — they split FROM statements but still end up with fat, slow images.

What Problem Does Multi-Stage Actually Solve?

Let’s skip the copy-paste and understand the mechanics first.

Single-stage builds have three core problems:

  1. Image bloat — every tool, intermediate file, and cache you need for building gets packed into the final artifact
  2. Expanded attack surface — gcc, make, curl, wget — tools you need during development become ticking time bombs in production
  3. Cache invalidation hell — change one line of code and your entire dependency layer might need to be re-downloaded

The core idea behind multi-stage builds is brutally simple: you don’t move the scaffolding into your new house, right?

Each FROM instruction starts a new stage. You can copy files from one stage to another, but only the last stage gets packaged into the final image. This means you can put your entire build environment in stage one, then copy just the compiled binary into a clean scratch or alpine image.

# Stage 1: Build
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server .

# Stage 2: Run
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]

The final image contains only: alpine, ca-certificates, and a compiled binary. Go toolchain? Gone. Source code? Gone. Dependency cache? Gone. This Dockerfile produces roughly a 15MB image. Compared to the original 1.2GB, that’s an 80x reduction.

Real-World Case: Slimming Down a Python ML Service

The Go example above is too clean. Let me walk through a real project our team tackled.

We had a Python ML inference service with heavy dependencies — numpy, pandas, scikit-learn, torch. The original Dockerfile:

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "serve.py"]

Guess the size? 4.7GB. Yes, you read that correctly. Almost 5GB, mostly from PyTorch’s CUDA dependencies.

We refactored it with multi-stage:

# Stage 1: Install dependencies
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Runtime
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
CMD ["python", "serve.py"]

Wait — does this actually help? Marginally, because Python dependencies still get carried over. The real improvement came from using python:3.11-slim in stage two instead of the full python:3.11, and isolating dependencies with --prefix.

But that wasn’t enough. We did two more things:

  1. Switched PyTorch from CUDA to CPU-only (our inference didn’t use GPU anyway)
  2. Used pip install --no-deps with manual dependency management to drop transitive deps

Final result: 4.7GB down to 890MB. Still huge? Sure, but that’s the Python scientific computing tax. The key win: zero build toolchains in the final image. Security scan pass rate went from 60% to 95%.

Advanced Multi-Stage Techniques

Named Stages and Selective Builds

Don’t use default numeric indices (0, 1, 2). Name your stages:

FROM node:18 AS dependencies
FROM node:18 AS build-stage
FROM nginx:alpine AS production

Then use --target to build up to a specific stage:

# Build only the dev stage, skip production optimizations
docker build --target dependencies -t myapp:dev .

This is incredibly useful when debugging your build process. I frequently run the dependencies stage in CI to cache layers, then run the full build separately.

Leveraging External Images as Stages

Here’s a trick many people don’t know — you can copy files from external images:

# Copy certs from official Alpine
FROM alpine:latest AS certs
RUN apk --no-cache add ca-certificates

# Copy base files from distroless
FROM gcr.io/distroless/static:nonroot AS base

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=base / /
COPY --from=builder /app/server /server

This lets you use scratch as your base while still having SSL certs and basic system files. Scratch is the smallest Docker image — it’s truly empty, zero filesystem. Your image contains only what you explicitly put in. Attack surface? Essentially zero.

Build Cache Optimization

The single biggest gotcha with multi-stage builds is cache invalidation. Here’s a common mistake:

FROM golang:1.21 AS builder
WORKDIR /app
COPY . .  # Copy everything first
RUN go mod download  # Then download deps
RUN go build -o server .

Every time you change any source file, COPY . . invalidates the cache, and go mod download has to re-run. The correct approach: copy dependency files first, download, then copy source:

FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./  # Only dependency files
RUN go mod download  # Cache only invalidates when go.mod/go.sum changes
COPY . .  # Source changes don't affect dep cache
RUN go build -o server .

This optimization alone saved 6 minutes per build on one of our projects — from 8 minutes down to 2, because 90% of commits don’t touch go.mod.

Language-Specific Best Practices

LanguageBase Image ChoiceBuild Stage ImageFinal Image Size (approx)Key Trick
Goscratch or distrolessgolang:alpine5-20MBCGO_ENABLED=0 static build
Rustscratchrust:slim5-30MB–target=x86_64-unknown-linux-musl
Node.jsnode:alpine or distrolessnode:1880-200MBnpm ci –only=production
Pythonpython:slim or distrolesspython:3.11-slim100-900MBpip install –no-cache-dir –prefix
Javaeclipse-temurin:alpinemaven:3.980-300MBLayer build, separate deps and code
.NETmcr.microsoft.com/dotnet/aspnet:8.0mcr.microsoft.com/dotnet/sdk:8.0100-400MBdotnet publish –self-contained false

Key point: don’t blindly chase the smallest image. Scratch is 0 bytes but debugging is a nightmare — no sh, no tools. When something breaks, you’re stuck. I generally recommend gcr.io/distroless/static for Go and Rust. It adds /etc/passwd, SSL certs, and basic debugging tools for about 2MB.

A Complete CI/CD Multi-Stage Template

Here’s a production-grade template I use, with four stages: dev dependencies, testing, build, and production:

# Stage 1: Dev dependencies
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# Stage 2: Test
FROM deps AS test
COPY . .
RUN yarn lint && yarn test

# Stage 3: Build
FROM deps AS build
COPY . .
RUN yarn build

# Stage 4: Production image
FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

In CI, you’d use it like this:

# GitHub Actions example
jobs:
  build:
    steps:
      - uses: actions/checkout@v4
      - name: Build test stage
        run: docker build --target test -t myapp:test .
      - name: Run security scan
        run: docker run myapp:test yarn audit
      - name: Build production image
        run: docker build --target production -t myapp:${{ github.sha }} .

Common Failure Modes

Failure 1: Wrong COPY –from path

# Wrong
COPY --from=builder /app/server /app/server
# Correct
COPY --from=builder /app/server /server

If you don’t set WORKDIR /app in stage two, the path /app/server doesn’t exist. The file ends up at /app/server relative to the root. I’ve seen someone debug this for an entire afternoon.

Failure 2: Leaving package managers in the final stage

FROM ubuntu:22.04
COPY --from=builder /app/server /server
RUN apt-get update && apt-get install -y curl  # Don't do this!

If you install curl, wget, vim, or any tools in the final stage, you’ve defeated the purpose of multi-stage. Each of these tools potentially introduces vulnerabilities. For debugging, use docker exec -it or kubectl debug with ephemeral containers.

Failure 3: Too many stages

Docker images support a maximum of 127 layers. Each FROM in a multi-stage build adds a layer. While you’re unlikely to hit the limit, too many layers hurt pull performance. I generally keep it to 3-5 stages.

Community Insights

A recent Reddit thread discussed a ComfyUI Docker security deployment using multi-stage builds for environment isolation. The comments were revealing — many people admitted they’d been using single-stage builds because “they couldn’t be bothered to change.”

One developer’s comment stuck with me: “I spent 3 hours optimizing my Dockerfile, dropping image size from 1.8GB to 120MB. Then I realized network bandwidth was the bottleneck, and image size didn’t matter.” Fair point — sometimes you optimize the wrong thing. But smaller images still win on deployment speed, security, and resource utilization over the long haul.

Another common complaint: multi-stage builds make Dockerfiles complex and hard for newcomers to understand. My advice: add comments in the Dockerfile, or include a simple flowchart in your README. Don’t sacrifice maintainability for “clean” code.

Performance Comparison: Multi-Stage vs Single-Stage

graph TD
    A[Source Code] --> B[Single-Stage Build]
    A --> C[Multi-Stage Build]
    B --> D[Image Size: 1.2GB]
    B --> E[Build Time: 180s]
    B --> F[Security Vulns: 45]
    C --> G[Image Size: 15MB]
    C --> H[Build Time: 200s]
    C --> I[Security Vulns: 2]

Note the build time — multi-stage is typically 10-20% slower due to the extra file copy operations. But this gap narrows significantly when CI caches are warm. And the pull/start time advantages of smaller images far outweigh those extra seconds.

MetricSingle-StageMulti-StageImprovement
Image Size1.2 GB15 MB98.75%
Build Time (cold)180s200s-11%
Build Time (cached)45s50s-11%
Pull Time (100Mbps)96s1.2s98.75%
Container Start Time3s0.5s83%
Security Vulns (CVE)45295.6%

When to Use It — and When to Skip

Multi-stage builds aren’t a silver bullet. Use them when:

  • Your app is a compiled language (Go, Rust, Java, C++)
  • Production has strict image size requirements (edge devices, IoT)
  • Your security team demands minimal attack surface
  • Image pull time is a bottleneck in your CI/CD pipeline

Skip them (or use cautiously) when:

  • Your app is pure interpreted script with no build step (simple Python script)
  • Your team isn’t comfortable with Docker and complex Dockerfiles would hurt maintenance
  • Your base image is already tiny (like python:alpine) and further optimization yields minimal gains

One last thing: don’t use multi-stage builds just to show off. I’ve seen someone split a single npm install into its own stage, resulting in a 50-line Dockerfile that performed identically to a 5-line single-stage build. Optimize with a goal, not for the sake of optimization.

FAQ

How much can multi-stage builds reduce image size?

It depends on the language and dependencies. Compiled languages (Go, Rust) typically see 90-99% reduction. Interpreted languages (Python, Node.js) typically see 30-60%. In extreme cases, a Go microservice can go from 1.2GB to 5MB.

What’s the difference between multi-stage builds and distroless images?

Multi-stage builds are a Dockerfile build strategy. Distroless is a set of minimal base images from Google. They work together: use distroless in the final stage of a multi-stage build for maximum effect.

How do I debug a specific stage in a multi-stage build?

Use docker build --target <stage-name> -t <image-name> . to build only up to that stage, then docker run -it <image-name> sh to enter and debug.

Do multi-stage builds impact CI/CD performance?

Cold builds are 10-20% slower due to extra file copy operations. But with cache hits, the difference is minimal. And the pull/deploy speed of smaller images far outweighs the slight build time increase.

Can I use different operating systems in different stages?

Yes. Each stage can use a different base image. For example, compile on Ubuntu and run on Alpine. But watch out for binary compatibility — binaries compiled against glibc (Ubuntu) may not run on musl-based (Alpine) systems.

References & Community Insights

The following authoritative resources were referenced for architectural best practices and specifications:

Elvin Hui

About the Author: Elvin Hui

Elvin is a Senior Infrastructure Engineer with 10+ years of experience spanning data centers, cloud-native architecture, and network security. Certified in CCNA and AWS Solutions Architecture, I focus on turning real-world production "war stories" into actionable, hardcore technical guides.