Shipping a 1GB Docker image containing compilers, debuggers, and build tools to production is a security nightmare. Multi-Stage Builds allow you to separate the build environment from the tiny runtime container.
TL;DR (Quick Summary)#
- Multi-Stage Builds: Use multiple
FROMstatements to build binaries in a heavy stage and copy ONLY the compiled output to a lightweight scratch/distroless stage. - Layer Caching: Order Dockerfile directives from least frequently changed (
COPY package.json) to most frequently changed (COPY . .). - Non-Root Security: Never run containers as
USER root. Always explicitly declareUSER nodeorUSER 10001. - .dockerignore: Exclude
.git,node_modules, and temporary logs from the build context.
1. Multi-Stage Build Architecture#
graph LR
subgraph Build Stage (golang:1.21-alpine)
Source["Go Source Code"] --> Compiler["go build -o server"]
Compiler --> Binary["Compiled Static Binary"]
end
subgraph Production Runtime (gcr.io/distroless/static-debian12)
Binary -->|COPY --from=builder| CleanContainer["15MB Distroless Container"]
end
2. Comparison: Naive vs Production Dockerfiles#
| Metric / Feature | Naive Dockerfile | Production Multi-Stage Dockerfile |
|---|---|---|
| Image Size | ~1.2 GB (Includes Node/Go/Python toolchains). | ~20 MB (Runtime binary/script only). |
| Security Surface | High (Shell, package managers, compilers present). | Minimal (No bash, no package manager, non-root user). |
| Build Time | Slow (Rebuilds dependencies on every code edit). | Blazing Fast (Leverages Docker layer cache). |
3. Step-by-Step Lab: Building a Production Go Microservice Image#
Let’s write a production-grade multi-stage Dockerfile for a Go API server.
The Production Dockerfile#
Create Dockerfile:
# ==========================================
# Stage 1: Build Environment
# ==========================================
FROM golang:1.21-alpine AS builder
# Set working directory
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git
# Copy dependency manifests first for layer caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source code and compile static binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-w -s" \
-o /app/server .
# ==========================================
# Stage 2: Minimal Distroless Production Runtime
# ==========================================
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /
# Copy only the compiled binary from Stage 1
COPY --from=builder /app/server /server
# Expose port
EXPOSE 8080
# Enforce non-root user execution
USER nonroot:nonroot
ENTRYPOINT ["/server"]Building and Verifying Image Sizes#
Build the image:
docker build -t go-api:prod .Compare image sizes:
docker images | grep go-apiExpected Terminal Output:
REPOSITORY TAG IMAGE ID CREATED SIZE
go-api prod a1b2c3d4e5f6 12 seconds ago 14.2MBThe production image is an incredible 14.2 MB!
4. Layer Caching Rules#
Docker caches each instruction (RUN, COPY, ADD) as a read-only layer. If a layer changes, all subsequent layers are invalidated!
# 🔴 Bad Ordering: Invalidates npm install cache on EVERY file edit!
COPY . .
RUN npm install
# 🟢 Good Ordering: npm install is cached UNLESS package.json changes!
COPY package*.json ./
RUN npm install
COPY . .5. Troubleshooting & Common Errors#
Error 1: exec user process caused: no such file or directory#
The Cause: You compiled a CGO/Go binary dynamically, and the scratch/distroless runtime image lacks the required C C-library (glibc).
The Fix: Disable CGO during compilation: CGO_ENABLED=0 GOOS=linux go build ....
Summary & Next Steps#
In this episode:
- We reduced container image sizes from 1GB to 14MB using Multi-Stage Builds.
- We optimized layer cache invalidation.
- We secured containers using Distroless images and non-root execution.
Next, we move to Episode 3: Docker Networking Deep Dive!

