Skip to main content

Multi-Stage Builds — Cut Your Docker Image Size by 90%

· 8 min read
Goel Academy
DevOps & Cloud Learning Hub

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:

LanguageBuild ImageBinary/App SizeWaste
Go1.1 GB15 MB98.6%
Java500 MB40 MB JAR92%
Node.js1.1 GB50 MB app + deps95.5%
Rust1.4 GB10 MB99.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

ApplicationSingle-StageMulti-StageReduction
Go API server1.1 GB12 MB (scratch)99%
Go API server1.1 GB15 MB (distroless)98.6%
Node.js API1.1 GB148 MB (alpine)86.5%
Java Spring Boot520 MB78 MB (jlink + alpine)85%
Python Flask450 MB180 MB (slim)60%
Rust CLI1.4 GB8 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.