Docker Multi-stage Build and Distroless Images: Shrink Your Container to Just a Few MB

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

My first production Docker image was 1.2 GB. The app inside it? A Node.js API that served JSON — maybe 50 KB of actual code. Everything else was build tools, package managers, shell utilities, and layers of OS bloat that had zero business being in a production container.

After switching to multi-stage builds with a distroless base image, that same app dropped to 68 MB. Security scanners stopped screaming at me. Deployments got faster. This isn’t a niche optimization — it’s table stakes for running containers in production.

Here’s how to do it.

Quick Start — Get a Lean Image in 5 Minutes

Theory later. First, the result. Here’s a multi-stage Dockerfile for a Go application that produces a final image under 10 MB:

# Stage 1: Build
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 -o server .

# Stage 2: Final image
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

Build and check the size:

docker build -t myapp:optimized .
docker image ls myapp:optimized
# REPOSITORY   TAG         IMAGE ID       SIZE
# myapp        optimized   a3b1c2d4e5f6   8.4MB

A naive single-stage build using golang:1.22 as the base lands at 800 MB+. Same binary. Completely different result.

Deep Dive — Why This Works

The Problem with Traditional Dockerfiles

Single-stage Dockerfiles do everything in one image: install build tools, compile code, run the app. Every package you pull in — compilers, package managers, debug utilities — gets baked into the final image permanently.

Each extra binary is a potential CVE. Your Go compiler doesn’t help the app serve requests faster. It just gives attackers more to probe. A shell gives them a foothold if they find an exploit.

How Multi-stage Builds Work

Multi-stage builds let you use multiple FROM statements in a single Dockerfile. Each FROM starts a fresh stage with its own filesystem. Between stages, you cherry-pick files using COPY --from=<stage>.

Only the last stage ships. Every compiler, build tool, and intermediate artifact gets discarded after the build completes — they never touch the final image.

# Stage 1: Build environment (discarded after build)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci                          # installs all deps including devDependencies
COPY . .
RUN npm run build                   # compile TypeScript, bundle, etc.

# Stage 2: Production environment (this is what ships)
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev               # production deps only
COPY --from=build /app/dist ./dist  # compiled output only
USER node
CMD ["node", "dist/index.js"]

What Are Distroless Images?

Distroless images, maintained by Google, take minimalism further than Alpine. They contain only your application and its runtime dependencies. No shell. No package manager. No utility binaries of any kind.

No shell means an attacker can’t exec into your container and run arbitrary commands even if they find an exploit. No apt, no curl, no wget — nothing to pivot with.

Available variants on gcr.io/distroless/:

  • static-debian12 — for statically compiled binaries (Go, Rust)
  • base-debian12 — adds glibc, for dynamically linked C binaries
  • cc-debian12 — adds libstdc++, for C++ apps
  • java21-debian12 — JRE only, no JDK
  • nodejs22-debian12 — Node.js runtime, no npm or shell
  • python3-debian12 — Python runtime only

Advanced Usage — Real Examples for Each Stack

Python FastAPI Application

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

# Stage 2: Distroless Python runtime
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Java Spring Boot Application

Java images are notoriously heavy. A default Spring Boot app with OpenJDK hits 500 MB without even trying. The three-stage approach below gets that under 150 MB — and with layered jars, subsequent builds are significantly faster:

# Stage 1: Build with Maven
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q  # cache dependencies separately
COPY src ./src
RUN mvn package -DskipTests -q

# Stage 2: Extract layers (Spring Boot layered jars)
FROM eclipse-temurin:21-jre AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# Stage 3: Distroless JRE
FROM gcr.io/distroless/java21-debian12
WORKDIR /app
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Splitting layers by change frequency is the key insight here. Dependencies only rebuild when pom.xml changes — which is rare. Your application code layer, which changes constantly, stays small and rebuilds fast.

Multi-stage for Frontend + Backend

# Stage 1: Build React frontend
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build

# Stage 2: Build Go backend
FROM golang:1.22-alpine AS backend-build
WORKDIR /backend
COPY backend/go.* ./
RUN go mod download
COPY backend/ .
COPY --from=frontend-build /frontend/dist ./static  # embed frontend
RUN CGO_ENABLED=0 go build -o app .

# Stage 3: Final distroless
FROM gcr.io/distroless/static-debian12
COPY --from=backend-build /backend/app /app
ENTRYPOINT ["/app"]

Practical Tips — What I Learned the Hard Way

Tip 1: Run as Non-root Even in Distroless

Distroless ships a dedicated non-root user. Use it:

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

The :nonroot tag runs as UID 65532. If someone escapes the container, they land as an unprivileged user on the host — not root. Defense in depth, not magic, but it matters.

Tip 2: Scan Before and After to See the Difference

# Install trivy
brew install aquasecurity/trivy/trivy  # macOS
# or
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh

# Scan before and after
trivy image myapp:before
trivy image myapp:optimized

# You'll typically see 200+ CVEs → 0-5 CVEs

Run this once. Seeing 247 CVEs collapse to 3 is more persuasive than any benchmark.

Tip 3: Debug Distroless Containers Without a Shell

No shell means debugging feels different at first. Two options that actually work:

# Distroless provides a :debug tag with busybox shell for local troubleshooting
FROM gcr.io/distroless/static-debian12:debug
# Use only locally — never ship :debug to production

# In Kubernetes, use ephemeral containers:
kubectl debug -it mypod --image=busybox --target=mycontainer

Tip 4: Pin Image Digests in Production

Tags like :latest or :nonroot can be silently updated. Pin to a digest for reproducible builds:

# Get the current digest
docker inspect --format='{{index .RepoDigests 0}}' gcr.io/distroless/static-debian12:nonroot
# gcr.io/distroless/static-debian12@sha256:abcd1234...

# Use digest in Dockerfile
FROM gcr.io/distroless/static-debian12@sha256:abcd1234...

Tip 5: Don’t Forget .dockerignore

Multi-stage builds don’t protect you from a bloated build context. A bare COPY . . will happily send your entire node_modules directory — sometimes gigabytes — to the Docker daemon before a single layer gets built.

node_modules/
.git/
*.log
dist/
.env
.env.*
tests/
docs/
*.md
.DS_Store

Quick Size Comparison Reference

Numbers from a typical Go REST API, same binary, different base images:

  • golang:1.22 (single stage): ~850 MB, 200+ CVEs
  • golang:1.22-alpine (single stage): ~300 MB, 15–30 CVEs
  • multi-stage + alpine final: ~12 MB, 5–10 CVEs
  • multi-stage + distroless/static: ~6–10 MB, 0–2 CVEs

Multi-stage builds plus distroless isn’t a micro-optimization. It’s the baseline. Smaller images pull faster in Kubernetes, cost less in container registries, and leave attackers almost nothing to work with.

Start with the Go example above. Adapt it for your stack. Once you see that first scan come back clean, bloated images stop being acceptable.

Share: