Docker has become the method of modern software delivery, but naïve Dockerfiles often produce bloated images that contain build tools, source code, and temporary files. Large images increase attack surface, slow down CI pipelines, and waste bandwidth. Multi‑stage builds solve these problems by allowing you to compile or assemble artifacts in one stage and copy only the final binaries into a lean runtime stage.
This article explains how multi‑stage Docker builds work, walks through a real‑world example for a Go microservice, shows how to apply the technique to Python, Node.js, and compiled C applications, and provides best‑practice tips for security and DevOps risk reduction.
What Is a Multi‑Stage Build?
A multi‑stage build is simply a Dockerfile that defines multiple FROM statements, each creating its own intermediate image. You can reference any earlier stage by name using the --from= flag in a COPY command. The final image is whatever you CMD or ENTRYPOINT at the end of the file, and everything else is discarded.
Example skeleton:
# Stage 1 – Build environment
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY . .
RUN go build -o app .
# Stage 2 – Runtime environment
FROM alpine:3.19
COPY --from=builder /src/app /usr/local/bin/app
EXPOSE 8080
CMD ["app"]
Only the second stage’s layers are present in the final image, resulting in a tiny Alpine base plus your compiled binary.
Why Multi‑Stage Improves DevOps
| Issue | Traditional Dockerfile | Multi‑stage Solution |
|---|---|---|
| Large size (hundreds of MB) | Build tools stay in image | Only runtime deps remain |
| Secret leakage (e.g., API keys used during build) | Keys may be left in layers | Secrets never copied to final stage |
| Slow CI/CD pipelines | Long docker push/pull times | Faster transfers, less storage |
| Vulnerability surface | Build‑time packages stay installed | Minimal base reduces CVE count |
Step 1 – Write a Simple Service
Create main.go:
package main
import (
"fmt"
"log"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from multi‑stage Go!")
}
func main() {
http.HandleFunc("/", handler)
log.Println("Listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Step 2 – Dockerfile with Multi‑Stage
# ---------- Builder ----------
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 -a -installsuffix cgo -o server .
# ---------- Runtime ----------
FROM alpine:3.19
LABEL maintainer="devops@example.com"
RUN addgroup -S app && adduser -S -G app app
WORKDIR /home/app
COPY --from=builder /app/server .
USER app
EXPOSE 8080
ENTRYPOINT ["./server"]
Why This Is Secure
CGO_ENABLED=0builds a static binary, eliminating the need for glibc in the runtime stage.- The runtime image runs as an unprivileged user (
app). - No source files or Go toolchain are present after copy.
Step 3 – Build and Verify Size
docker build -t go‑microservice:latest .
docker images | grep go‑microservice
Typical output:
go-microservice latest a1b2c3d4e5f6 12MB 2 minutes ago
Contrast with a monolithic Dockerfile that might be >150 MB.
Example 2 – Python Application with Dependencies
Python projects often rely on pip to install many libraries, which can bloat images. Multi‑stage builds let you compile wheels in a builder and copy only the needed packages.
Project Layout
app/
├── requirements.txt
└── main.py
requirements.txt
flask==3.0.2
requests==2.31.0
main.py
from flask import Flask, jsonify
import requests
app = Flask(__name__)
@app.route("/")
def hello():
return jsonify(message="Hello from multi‑stage Python!")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Dockerfile
# ---------- Builder ----------
FROM python:3.12-slim AS builder
WORKDIR /src
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt --target=/install
# ---------- Runtime ----------
FROM python:3.12-alpine
LABEL maintainer="devops@example.com"
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=builder /install /usr/local/lib/python3.12/site-packages
COPY app/ .
EXPOSE 5000
CMD ["python", "main.py"]
Key Points
- The builder uses
python:3.12-slimwhich includes build tools likegcc. - After installing dependencies to
/install, we copy that directory into the Alpine runtime, which does not have any compilers. - This results in a final image of ~70 MB versus >200 MB for a single‑stage approach.
Build and Test
docker build -t py‑multi:latest .
docker run --rm -p 5000:5000 py‑multi:latest
Visit http://localhost:5000 to see the JSON response.
Example 3 – Node.js with Native Addons
Node projects that compile native addons (e.g., bcrypt) need a full build environment. Use multi‑stage to keep only compiled binaries.
Dockerfile
# ---------- Builder ----------
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # install production deps (includes native compile)
COPY . .
RUN npm run build # if you have a build step (e.g., TypeScript)
# ---------- Runtime ----------
FROM node:20-alpine
LABEL maintainer="devops@example.com"
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist # if you built a dist folder
EXPOSE 3000
CMD ["node", "dist/index.js"]
The builder stage compiles native addons using the Alpine toolchain, then discards npm itself and any development dependencies.
Example 4 – C Application with Static Linking
When building a low‑level service in C, you may need glibc. The trick is to compile statically in the builder and copy only the binary.
Dockerfile
# ---------- Builder ----------
FROM gcc:13 AS builder
WORKDIR /src
COPY . .
RUN gcc -static -O2 -o hello_world main.c
# ---------- Runtime ----------
FROM scratch
COPY --from=builder /src/hello_world /hello_world
EXPOSE 8080
ENTRYPOINT ["/hello_world"]
scratch is an empty image, so the final container contains only the binary and nothing else. This yields a ~1 MB image.
Best Practices for Multi‑Stage Builds
- Name Stages Explicitly – Use
AS builder,AS runtimeetc., to improve readability. - Minimize Layers – Combine related commands with
&&to reduce intermediate layers, especially in the final stage. - Leverage
.dockerignore– Exclude source files, test data, and local caches from being sent to the daemon. Example:*.log node_modules .git __pycache__ - Use Trusted Base Images – Prefer official minimal images (
alpine,scratch) for runtime stages. Verify image digests if security is critical. - Scan Final Image – Run tools like
trivyordocker scanon the final stage to ensure no known CVEs are present.trivy image my‑app:latest - Avoid Secrets in Build Args – Never pass API keys via
ARG. If you need them for a build step, inject them at runtime instead of copying into the final stage. - Set Non‑Root User – Always create and switch to an unprivileged user in the runtime stage (
USER app).
Automating Multi‑Stage Builds in CI/CD
Most CI systems (GitHub Actions, GitLab CI, Jenkins) already support docker build. To enforce multi‑stage builds:
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Build and push multi‑stage image
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myrepo/myapp:${{ github.sha }} \
--push .
The docker buildx command automatically uses the Dockerfile’s stages; you do not need extra flags.
Reducing DevOps Risk with Multi‑Stage
- Predictable Deployments – Smaller images mean fewer unexpected runtime dependencies.
- Faster Rollbacks – Pulling a 10 MB image is almost instantaneous compared to a 200 MB one, enabling quick recovery.
- Lower Cost – Reduced storage on container registries and less bandwidth usage in CI pipelines translate to cost savings.
Conclusion
Multi‑stage Docker builds are a simple yet powerful technique that transforms bloated images into lean, secure artifacts. By separating build-time tooling from runtime dependencies, you shrink image size, eliminate secret leakage, improve pipeline speed, and reduce the attack surface. The examples above cover Go, Python, Node.js, and native C workloads, showing how universal this approach is across languages. Adopt the best‑practice checklist, integrate scanning tools, and enforce multi‑stage builds in your CI pipelines to dramatically lower DevOps risk while delivering faster, more reliable containers.
Leave a Reply