Multi-Stage Builds — Cut Your Docker Image Size by 90%
Your production Go binary is 15 MB. Your Docker image is 1.1 GB. That is not a rounding error — it is a sign that you are shipping an entire operating system, a compiler toolchain, and hundreds of packages you will never use at runtime. Multi-stage builds fix this in a way that feels almost too easy.
Why Image Size Matters
A bloated Docker image is not just a disk space problem. It is a compounding problem that hits you in three places simultaneously.
Security surface area. Every package in your image is a potential CVE. A Go binary on scratch has zero OS packages to patch. The same binary on golang:1.22 inherits 400+ packages and dozens of known vulnerabilities. Run docker scout quickview on both and the difference is staggering.
Deployment speed. A 1 GB image takes 45 seconds to pull over a fast network. A 15 MB image takes under a second. In a Kubernetes rolling update across 20 pods, that difference is the gap between a 2-minute deploy and a 15-minute deploy.
Cost. Registry storage, network transfer, and cold start times all scale with image size. On AWS ECR at $0.10/GB/month, the difference between a 50 MB and a 1 GB image across 100 services and 30 tags is roughly $250/month in storage alone.
The Single-Stage Problem
Here is what most developers start with for a Go application:
FROM golang:1.22
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server .
CMD ["./server"]
docker build -t myapp-fat .
docker images myapp-fat
# REPOSITORY TAG SIZE
# myapp-fat latest 1.1GB
The final image includes the entire Go toolchain (800+ MB), all source code, cached modules, and the full Debian operating system. Your actual binary is 15 MB. You are shipping 98.6% waste.
The same problem applies to every compiled or build-step language:
| Language | Build Image | Binary/App Size | Waste |
|---|---|---|---|
| Go | 1.1 GB | 15 MB | 98.6% |
| Java | 500 MB | 40 MB JAR | 92% |
| Node.js | 1.1 GB | 50 MB app + deps | 95.5% |
| Rust | 1.4 GB | 10 MB | 99.3% |
Multi-Stage Build Syntax
The core idea is simple: use one stage to build, another stage to run. The build stage has all the tools. The runtime stage has only what the application needs.
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o server .
# Stage 2: Runtime
FROM alpine:3.19
COPY --from=builder /app/server /server
CMD ["/server"]
The key instruction is COPY --from=builder. It reaches into the builder stage and copies only the compiled binary. Everything else — the Go compiler, source code, module cache — is discarded. The final image is based on alpine:3.19, which is 7 MB.
Real Example: Go (1.1 GB to 15 MB)
For maximum reduction, use scratch or distroless as the final stage. A statically compiled Go binary needs no OS at all.
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /server /server
USER 65534:65534
ENTRYPOINT ["/server"]
docker build -t myapp-slim .
docker images myapp-slim
# REPOSITORY TAG SIZE
# myapp-slim latest 12.4MB
The CGO_ENABLED=0 flag produces a statically linked binary. The -ldflags="-s -w" strips debug symbols. We copy the CA certificates so the binary can make HTTPS calls. That is it — no shell, no package manager, nothing for an attacker to exploit.
Real Example: Node.js (1.1 GB to 150 MB)
Node.js applications cannot run on scratch because they need the Node runtime. But you can still drop build tools, devDependencies, and source TypeScript.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/package.json ./
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]
docker images
# node-fat latest 1.1GB (single-stage with devDependencies)
# node-slim latest 148MB (multi-stage, production only)
The builder stage runs npm ci (with devDependencies for the build), compiles TypeScript, then prunes to production-only deps. The final stage copies only the compiled JavaScript and production node_modules.
Real Example: Java (500 MB to 80 MB)
Java images with the full JDK are massive. The runtime only needs the JRE — or better, a custom minimal JRE built with jlink.
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests
# Create a minimal custom JRE
RUN jlink --add-modules java.base,java.logging,java.sql,java.net.http \
--strip-debug --no-man-pages --no-header-files \
--compress=2 --output /custom-jre
FROM alpine:3.19
COPY --from=builder /custom-jre /opt/java
COPY --from=builder /app/target/*.jar /app/app.jar
ENV PATH="/opt/java/bin:$PATH"
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 8080
CMD ["java", "-jar", "/app/app.jar"]
docker images
# java-fat latest 520MB (JDK + Maven + sources)
# java-slim latest 78MB (custom JRE + JAR only)
The jlink step is the secret weapon for Java. Instead of shipping the entire 200 MB JRE, you build a custom runtime with only the modules your application actually uses.
Real Example: Python
Python is trickier because it is interpreted, not compiled. But you can still use multi-stage builds to avoid shipping build tools like gcc and header files.
FROM python:3.12-slim AS builder
WORKDIR /app
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 --user --no-cache-dir -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
RUN addgroup --system app && adduser --system --group app
COPY --from=builder /root/.local /home/app/.local
COPY --chown=app:app . .
USER app
ENV PATH="/home/app/.local/bin:$PATH"
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]
The builder installs gcc and header files needed to compile C extensions (psycopg2, etc.). The final image gets only the compiled wheels — no compiler, no headers.
Before and After Comparison
| Application | Single-Stage | Multi-Stage | Reduction |
|---|---|---|---|
| Go API server | 1.1 GB | 12 MB (scratch) | 99% |
| Go API server | 1.1 GB | 15 MB (distroless) | 98.6% |
| Node.js API | 1.1 GB | 148 MB (alpine) | 86.5% |
| Java Spring Boot | 520 MB | 78 MB (jlink + alpine) | 85% |
| Python Flask | 450 MB | 180 MB (slim) | 60% |
| Rust CLI | 1.4 GB | 8 MB (scratch) | 99.4% |
When NOT to Use Multi-Stage Builds
Multi-stage builds are not always the right choice.
Development images. When you need debugging tools, profilers, and a shell, a single fat image is fine. Use multi-stage for production and a simple Dockerfile for development.
Simple scripts. A single Python script with no build step does not benefit from multi-stage complexity. Just use python:3.12-slim directly.
When build and runtime base are the same. If your application needs the same OS packages at build time and runtime, multi-stage adds complexity without significant size reduction.
Debugging Multi-Stage Builds
When something goes wrong in a multi-stage build, you can target a specific stage to debug it.
# Build only the builder stage and stop
docker build --target builder -t myapp-debug .
# Now inspect the build stage
docker run -it myapp-debug /bin/sh
# List files to verify the binary was compiled
ls -la /app/server
# Check the binary works
./server --version
# See the size of each stage
docker build --target builder -t myapp-builder .
docker build -t myapp-final .
docker images | grep myapp
# myapp-builder latest 1.1GB
# myapp-final latest 12MB
You can also use intermediate stages for testing:
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o server .
FROM builder AS tester
RUN go test ./...
FROM scratch
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
# Run tests as part of the build
docker build --target tester -t myapp-test .
Wrapping Up
Multi-stage builds are the single most impactful optimization you can make to your Docker workflow. The pattern is always the same — install build tools in one stage, copy artifacts to a minimal runtime stage. For Go and Rust, that runtime can be scratch (literally nothing). For Java, use jlink to build a custom JRE. For Node.js, prune devDependencies. The result is smaller images that pull faster, deploy faster, and expose fewer vulnerabilities.
In the next post, we will cover Docker Security — image scanning with Trivy and Docker Scout, running as non-root, read-only filesystems, and managing secrets without baking them into your images.
