Multi-Stage Build Architectures & Minimal Base Image Optimization
Reducing production image sizes from 1.4GB to 12MB while eliminating security vulnerabilities.
Part 7 in Series — Catch up on the previous article: Dockerfile Instructions and Layer Mechanics: How Build Caching Works Under the Hood (Part 6) before diving into this post.
A security audit scans a production Go microservice container image and flags 412 high-severity Common Vulnerabilities and Exposures (CVEs).
The image size is 1.4 Gigabytes.
Inspection reveals why: the production image contains the full Go compiler suite (go), gcc, make, header files (glibc-devel), package managers (apt), text editors, and debugging tools.
None of these build tools are needed to run the compiled binary in production. They exist in the image solely because the build step required them.
By refactoring the Dockerfile into a Multi-Stage Build Pipeline:
# Stage 1: Build Stage (Includes compiler)
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
# Stage 2: Production Stage (Minimal Scratch Runtime)
FROM scratch
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
The resulting production image size collapses from 1.4 Gigabytes down to 12 Megabytes, and the CVE count drops from 412 down to ZERO.
How do Multi-Stage builds and minimal base images eliminate bloat and harden container security?
1. The Single-Stage Anti-Pattern
In traditional single-stage Dockerfiles, every instruction (RUN apt-get install, COPY, RUN make) appends a new read-only layer to the final image.
+---------------------------------------------------------------+
| Layer 4: Compiled App Binary (12 MB) |
+---------------------------------------------------------------+
| Layer 3: Build Artifacts & Object Files (350 MB) [UNNEEDED] |
+---------------------------------------------------------------+
| Layer 2: Go SDK & GCC Compilers (800 MB) [UNNEEDED] |
+---------------------------------------------------------------+
| Layer 1: Ubuntu Base OS (75 MB) [UNNEEDED] |
+---------------------------------------------------------------+
FINAL IMAGE SIZE: 1.24 GB (Contains attack surface & bloat!)
Consequences of Large Images:
- Increased Attack Surface: Extra utilities (
curl,bash,python) give attackers post-exploitation tools inside compromised containers. - Slow Deployment Latency: Pulling 1.4GB images across Kubernetes clusters during autoscaling events delays pod startup times by minutes.
- High Storage Costs: Storing thousands of 1.4GB image versions in container registries consumes terabytes of cloud storage.
2. Multi-Stage Build Architecture
Multi-Stage builds allow you to use multiple FROM instructions in a single Dockerfile.
Each FROM instruction begins a new, clean build stage with a fresh root filesystem. You selectively copy only the compiled binary artifacts from earlier build stages into the final runtime stage.
[ STAGE 1: Builder Stage ] [ STAGE 2: Runtime Stage ]
FROM golang:1.21 AS builder FROM gcr.io/distroless/static
- Installs dependencies - Clean minimal environment
- Compiles source code
- Generates /app/binary --( COPY --from=builder )--> Contains ONLY /app/binary
FINAL IMAGE SIZE: 15 MB
Syntax and Workflow Example
# ===================================================
# STAGE 1: Compilation Stage
# ===================================================
FROM node:18-alpine AS build-stage
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # Generates static HTML/JS assets in /app/dist
# ===================================================
# STAGE 2: Production Web Server Stage
# ===================================================
FROM nginx:1.25-alpine AS production-stage
# Copy ONLY static web assets from build-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
During execution, BuildKit compiles the application in build-stage, discards the 900MB Node.js SDK and node_modules directory, and copies only the 4MB /app/dist assets into the lightweight Nginx image.
3. Minimal Base Image Options
Selecting the right production base image determines container security, glibc compatibility, and final image size.
Image Size (MB)
150MB +-----------------------------------------------------------+
| Debian / Ubuntu (~120MB) |
50MB +-----------------------------------+ |
| Alpine Linux (~5MB) | |
5MB +-------------------+---------------+ |
| Distroless (~2MB) | | |
0MB +-------------------+---------------+-----------------------+
scratch (0MB) Distroless Alpine Debian/Ubuntu
A. Full OS Images (ubuntu:22.04, debian:bookworm)
- Size: 75MB – 150MB.
- Pros: Includes full
glibc, package managers (apt), and standard Linux CLI utilities. High developer familiarity. - Cons: Large memory footprint, higher CVE count.
B. Alpine Linux (alpine:3.18)
- Size: ~5MB.
- Pros: Ultra-lightweight, minimal default package set.
- Cons: Uses
musl libcinstead of GNUglibc. C/C++ or Python C-extensions compiled againstglibcmay encounter runtime segmentation faults or performance degradation unless recompiled.
C. Google Distroless (gcr.io/distroless/static or base)
- Size: ~2MB – 20MB.
- Pros: Contains only your application and runtime dependencies (like
glibcor OpenJDK). Contains no package manager, no shell (/bin/sh), and no text editors. - Cons: Cannot execute
docker exec -it container shto debug live containers without ephemeral debug containers.
D. Scratch (scratch)
- Size: 0MB (An empty explicit base image).
- Pros: Zero overhead. Ideal for statically linked C, Go, or Rust binaries (
CGO_ENABLED=0). - Cons: Contains no SSL root certificates (
/etc/ssl/certs) or timezone databases unless manually copied into the image!
Base Image Comparison Matrix
| Base Image | Size | Standard C Library | Includes Shell (/bin/sh)? | Package Manager | Recommended For |
|---|---|---|---|---|---|
ubuntu:22.04 | ~75 MB | glibc | Yes | apt | Complex legacy apps requiring standard Linux toolchains |
alpine:3.18 | ~5 MB | musl libc | Yes | apk | Lightweight microservices tolerant of musl libc |
distroless/base | ~20 MB | glibc | No | None | Secure production Java, Python, Node, or C++ applications |
scratch | 0 MB | None | No | None | Statically compiled Go, Rust, or C binaries |
Summary & Next Steps
Multi-stage builds decouple application compilation from runtime execution:
- Multi-Stage Builds isolate build-time dependencies (SDKs, compilers) from production runtimes using selective
COPY --from=stagesteps. - Minimal Base Images (Alpine, Distroless, Scratch) reduce container footprints from gigabytes to megabytes while eliminating CVE vulnerabilities.
- Static Binaries on
scratchoffer the highest security posture for compiled microservices.
In the next article, we inspect Container Registries: Content-Addressable Storage, Push/Pull Protocols, Tags vs Digests.
References & Further Reading
- IETF. RFC 7348 — Virtual eXtensible Local Area Network (VXLAN). Internet Engineering Task Force.
- Linux Net-Tools. ip-link(8) — Network Device Configuration (Bridge, Macvlan, veth). Linux Man Pages.
- Docker Inc. Docker Container Networking Architecture (CNM). Docker Docs.
Part 8: Container Registries: Content-Addressable Storage, Push/Pull Protocols, Tags vs Digests
Continue to Part 8 →