Multi-stage Construction

An image shrinks from 1.2 GB to 12 MB—multi-stage builds are the most effective technique for reducing image size.

1. What You'll Learn


2. A True Story from a Go Developer

(1) Pain Point: A 1.2 GB application image

Alice’s Go application image is 1.2 GB—because she used golang:1.22 as the base image, which includes the full Go compiler, source code, and toolchain. But the production environment only needs a 12 MB compiled binary. Each deployment takes 5 minutes to download the image, and the cloud server with 50 MB bandwidth slows down every time a release is made.

(2) A Multi-Stage Construction Approach

Bob taught her to use multi-stage builds: "In the first stage, use golang:1.22 to compile; in the second stage, copy the compiled output from Alpine."

DOCKERFILE
# Stage 1: Build
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o server

# Stage 2: Run (only the binary, no Go compiler)
FROM alpine:3.19
COPY --from=builder /src/server /usr/local/bin/
CMD ["server"]

(3) Benefit: The image size was reduced from 1.2 GB to 12 MB

The image size was reduced by a factor of 100, deployment and download times were cut from 5 minutes to 5 seconds, and security vulnerabilities were reduced from over 200 to 10 (the compiler is not included in the production image).


3. Principles of Multi-Stage Construction

(1) Single-Stage vs. Multi-Stage Comparison

100%
graph TB
    subgraph Single["Single-stage Build"]
        S1["golang:1.22<br/>800 MB base"] --> S2["go build<br/>Compilation Output"] --> S3["Final Image<br/>1.2 GB<br/>(Includes a compiler+Source Code)"]
    end
    subgraph Multi["Multi-stage Build"]
        M1["Stage 1: golang:1.22<br/>800 MB (discarded)"] -->|"COPY --from"| M3["Stage 2: alpine:3.19<br/>7 MB base"]
        M1 --> M2["go build<br/>Compilation Output"]
        M2 -->|"Copy only the binary data"| M3
        M3 --> M4["Final Image<br/>12 MB<br/>(Binary Only)"]
    end

(1) Core Mechanism

Stage Purpose Contents Final Image
Builder Stage Compilation/Build Compiler, source code, build tools ❌ Discard
Runner Stage Execution Copy only necessary artifacts ✅ Retain
📌 Key Point: Multi-stage building does not compress the image; rather, it prevents unnecessary items from being included in the image from the outset. All layers from the Builder stage are discarded, and only layers from the Runner stage are included in the final image.


4. Hands-On Multi-Stage Builds

▶ Example: Multi-stage builds in Go (Difficulty: ⭐⭐)

DOCKERFILE
# ============================================
# Stage 1: Build the Go binary
# ============================================
FROM golang:1.22 AS builder

WORKDIR /src

# Copy dependency files first (cache optimization)
COPY go.mod go.sum ./
RUN go mod download

# Copy source code and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server

# ============================================
# Stage 2: Create minimal runtime image
# ============================================
FROM alpine:3.19

# Install CA certificates (for HTTPS requests)
RUN apk --no-cache add ca-certificates

# Copy only the compiled binary from builder
COPY --from=builder /src/server /usr/local/bin/server

# Run as non-root user
RUN adduser -D -u 1000 appuser
USER appuser

EXPOSE 8080
CMD ["server"]
BASH
# Build the multi-stage image
docker build -t go-app:1.0 .

# Check the final image size
docker images go-app:1.0
💻 Output:

TEXT
REPOSITORY   TAG   IMAGE ID       SIZE
go-app       1.0   a1b2c3d4e5f6   12.5MB

▶ Example: Build with Node.js → Run with Nginx (Difficulty: ⭐⭐⭐)

Front-end projects such as React and Vue: Static files are generated during the build phase, and Nginx is used to serve HTTP requests during runtime.

DOCKERFILE
# ============================================
# Stage 1: Build React application
# ============================================
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ============================================
# Stage 2: Serve with Nginx
# ============================================
FROM nginx:1.25-alpine

# Copy built assets from builder stage
COPY --from=builder /app/build /usr/share/nginx/html

# Custom Nginx config for SPA routing
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
💡 Tip: Multi-stage builds are most effective for front-end projects—node_modules might be 500+ MB, but the build output (HTML/CSS/JS) is usually only 5–20 MB.

▶ Example: Cloning a Python venv (Difficulty: ⭐⭐)

Python projects can also be built in multiple stages: the first stage creates a virtual environment, and the second stage simply copies the virtual environment and the application code.

DOCKERFILE
# Stage 1: Build virtual environment
FROM python:3.12 AS builder

WORKDIR /app
COPY requirements.txt .
RUN python -m venv /opt/venv && \
    /opt/venv/bin/pip install --no-cache-dir -r requirements.txt

# Stage 2: Run with venv only
FROM python:3.12-slim

WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY . .

ENV PATH="/opt/venv/bin:$PATH"
CMD ["python", "app.py"]

5. Selecting a Base Image to Run

(1) Comparison of the Images of Three Minimal Bases

Base Image Size Shell Included Package Manager Included libc Included Use Cases
scratch 0 MB Statically compiled Go/Rust binaries
distroless 2–20 MB ✅ (glibc) From Google, extremely secure
alpine 7 MB ✅ (ash) ✅ (apk) ✅ (musl) When debugging is required

(2) Notes on the Scratch Image

DOCKERFILE
# Scratch: absolutely nothing in the image
FROM scratch
COPY --from=builder /src/server /server
CMD ["/server"]
⚠️ Note: The scratch image does not include a shell, ca-certificates, or time zone data. Go programs must be statically compiled CGO_ENABLED=0 to run on scratch. If your program requires HTTPS requests or timestamped logs, it is safer to use alpine.


6. Advanced Techniques for Multi-Stage Builds

▶ Example: Stage Naming and --target Build Selection (Difficulty: ⭐⭐)

DOCKERFILE
# Named stages for clarity
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o server

# Alternative: development stage with hot-reload tools
FROM golang:1.22 AS dev
WORKDIR /src
RUN go install github.com/cosmtrek/air@latest
COPY . .
CMD ["air"]

# Production: minimal runtime
FROM alpine:3.19 AS prod
COPY --from=builder /src/server /usr/local/bin/
CMD ["server"]
BASH
# Build only the dev stage
docker build --target dev -t myapp:dev .

# Build only the prod stage
docker build --target prod -t myapp:prod .

▶ Example: --target for debugging intermediate artifacts (Difficulty: ⭐⭐⭐)

BASH
# Build and inspect the builder stage
docker build --target builder -t myapp:builder .

# Run the builder image to debug build issues
docker run -it myapp:builder bash

# Check the compiled binary
docker run --rm myapp:builder ls -la /src/server

7. Reference for Compilation Output Sizes by Language

Language Base Image Build Output Final Image Reduction Ratio
growing: 1.22 (780 MB) Static binary 12 MB alpine: 12 MB 65x
Rust rust:1.75 (1.5 GB) Static binary 8 MB alpine: 15 MB 100x
Node.js node:20 (1.1 GB) build/ 5-20 MB nginx-alpine: 25 MB 44x
Java eclipse-temurin:21 (450 MB) JAR 30 MB jre-alpine: 170 MB 2.6x
Python python:3.12 (1 GB) venv/ 50 MB slim: 200 MB 5x

8. Complete Example: Multi-Stage Build for a Go Web Application

DOCKERFILE
# ============================================
# Multi-stage Dockerfile for Go web application
# Stage 1: Build with dependency caching
# Stage 2: Minimal Alpine runtime
# ============================================

# ---------- Stage 1: Builder ----------
FROM golang:1.22-alpine AS builder

# Install git (for go mod download with private repos)
RUN apk add --no-cache git

WORKDIR /src

# Cache: copy dependency files first
COPY go.mod go.sum ./
RUN go mod download

# Copy source code
COPY . .

# Build static binary (CGO_ENABLED=0 for scratch/alpine)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-w -s" -o /app/server

# ---------- Stage 2: Runtime ----------
FROM alpine:3.19

# Install runtime dependencies
RUN apk --no-cache add ca-certificates tzdata

# Create non-root user
RUN adduser -D -u 1000 appuser

WORKDIR /app

# Copy only the binary from builder
COPY --from=builder /app/server .

# Set ownership
RUN chown -R appuser:appuser /app

USER appuser

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
  CMD wget -qO- http://localhost:8080/health || exit 1

CMD ["/app/server"]
BASH
# Build
docker build -t go-web:1.0 .

# Run
docker run -d -p 8080:8080 --name go-web go-web:1.0

# Verify size and health
docker images go-web:1.0
docker inspect go-web --format='Size: {{.Size}}, Health: {{.State.Health.Status}}'
💻 Output:

TEXT
REPOSITORY   TAG   SIZE
go-web       1.0   15.2MB

Size: 15974400, Health: healthy

❓ FAQ

Q Does a multi-stage build affect build speed?
A It does not have a significant impact. Layers from the Builder stage are still cached—if the code hasn’t changed, go build uses the cache directly. Only the final image does not include the Builder layers. Build speed is nearly the same as a single-stage build, but the image size is significantly reduced.
Q Can I use multi-stage builds for debugging?
A Yes. Use --target builder to build up to the Builder stage, then use docker run -it myapp:builder bash to enter the debugging stage. You can also define a dev stage in the Dockerfile that includes debugging tools (such as dlv or air).
Q How do I debug without a shell in the scratch image?
A Since the scratch image doesn't have a shell, you can't docker exec to enter it. Debugging methods: ① Build a debug image using --target builder; ② Temporarily change FROM scratch to FROM alpine for debugging; ③ Use docker cp on the host to copy the log files out.
Q Can secrets be passed through multi-stage builds?
A Do not include secrets in the Builder stage—although the Builder layer is not included in the final image, docker history is still visible. Use --mount=type=secret (a BuildKit feature) to securely pass secrets: RUN --mount=type=secret,id=ssh_key cp /run/secrets/ssh_key /root/.ssh/id_rsa.
Q Is the distroless image secure?
A It is very secure. The distroless image maintained by Google does not include a shell, a package manager, or any non-essential tools, resulting in an extremely small attack surface. However, debugging is difficult (since there is no shell), so we recommend using Alpine for development and distroless for production (with external debugging via logs and monitoring).
Q Why do Go images use Alpine instead of Scratch?
A Alpine provides ca-certificates (required for HTTPS requests) and time zone data (required for log timestamps), as well as a shell for emergency debugging. The extra 5 MB is well worth it. Scratch is only used in scenarios requiring extreme optimization (such as embedded devices).

📖 Summary


📝 Exercises

  1. Basic Exercise (Difficulty ⭐): Compare the sizes of Go application images built using single-stage and multi-stage builds—first use FROM golang:1.22 for a single-stage build, then use a multi-stage build, and record the difference in size between the two images.
  2. Advanced Exercise (Difficulty ⭐⭐): Build a React application in multiple stages: node:20-alpine Build → nginx:1.25-alpine Run. Compare the final image sizes of the Node.js base image and the Nginx base image.
  3. Challenge (Difficulty: ⭐⭐⭐): Use docker history to analyze the size of each layer in a multi-stage image build, and explain why the layers from the Builder stage are not included in the final image.
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏