Docker Multi-Stage Build Optimization for Smaller Images
Reduce Docker image size with multi-stage builds and proper layering
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Multi-stage builds let you use multiple FROM statements in a single Dockerfile. Each stage starts fresh, and you copy only the artifacts you need from previous stages. This drops build tools, dev dependencies, and intermediate files from the final image, reducing size by up to 90 percent.
When to Use
- Your Docker image is too large (over 500MB for a simple app)
- You ship build tools (compilers, SDKs, node_modules) in production images
- You want smaller attack surface by excluding unnecessary binaries
- You need different base images for build vs runtime (e.g., Go build with golang, run with scratch)
Solution
Node.js multi-stage build
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Python multi-stage with distroless
# Stage 1: Build dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Stage 2: Runtime
FROM gcr.io/distroless/python3-debian12 AS runtime
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY . .
ENV PATH="/opt/venv/bin:$PATH"
USER nonroot:nonroot
EXPOSE 8000
CMD ["-m", "gunicorn", "app:app", "--bind", "0.0.0.0:8000"]
Go multi-stage with scratch
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
# Stage 2: Runtime (scratch = empty image)
FROM scratch AS runtime
COPY --from=builder /build/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]
Java multi-stage with Gradle
# Stage 1: Build
FROM gradle:8.7-jdk21-alpine AS builder
WORKDIR /build
COPY build.gradle settings.gradle ./
COPY src ./src
RUN gradle bootJar --no-daemon
# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=builder /build/build/libs/*.jar app.jar
USER temurin
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
.dockerignore for smaller context
# .dockerignore
node_modules
dist
.git
.gitignore
.env
.env.local
*.md
.vscode
.idea
coverage
.nyc_output
Dockerfile
docker-compose*.yml
Layer caching optimization
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependency files first (cached unless package.json changes)
COPY package*.json ./
RUN npm ci
# Copy source (invalidates cache only when source changes)
COPY . .
RUN npm run build
Explanation
Each FROM instruction starts a new stage. Docker builds all stages but only the final stage becomes the output image. Use COPY --from=builder to pull artifacts from earlier stages.
Key concepts:
- Stage naming: Use
AS builder,AS runtimeto name stages. Reference them withCOPY --from=builder. - Distroless images: Google’s distroless images have no shell, package manager, or extra binaries. They reduce size and attack surface significantly.
- Scratch images: The smallest possible base (0 bytes). Only works for statically compiled binaries like Go.
- Layer caching: Docker caches each layer. Put rarely-changing instructions (dependency install) before frequently-changing ones (source code copy). This way, changing source code reuses the cached dependency layer.
.dockerignore: Reduces the build context sent to the Docker daemon. Excludenode_modules,.git, build artifacts, and IDE files.npm ci --omit=dev: Installs only production dependencies. Combined with multi-stage, the final image has no devDependencies.- Non-root user: Run the container as a non-root user (
USER node,USER nonroot) for security.
Variants
| Base Image | Size | Shell | Use When |
|---|---|---|---|
| scratch | ~0 MB | None | Static binaries (Go, Rust) |
| distroless | ~20-50 MB | None | Minimal attack surface |
| alpine | ~5-10 MB | Yes | General purpose, small |
| slim | ~20-80 MB | Yes | Debian-based, compatibility |
| full | ~300-900 MB | Yes | Development, debugging |
Guidelines
- Use multi-stage builds for any non-trivial application.
- Choose the smallest base image that works (scratch > distroless > alpine > slim > full).
- Copy dependency files before source code to maximize layer cache hits.
- Use
npm ciinstead ofnpm installfor reproducible builds. - Exclude dev dependencies with
--omit=dev(npm) or--no-dev(pip). - Add a
.dockerignorefile to reduce build context. - Run containers as non-root users.
- Strip debug symbols from compiled binaries (
-ldflags="-s -w"for Go). - Clean package caches (
npm cache clean --force,pip cache purge). - Tag images with specific versions, not
latest.
Common Mistakes
- Copying the entire build context without
.dockerignore. This sendsnode_modulesand.gitto the daemon, slowing builds. - Putting
COPY . .before dependency installation. Every source change invalidates the dependency cache. - Using
npm installinstead ofnpm ci.npm installcan modifypackage-lock.jsonand produce non-reproducible builds. - Shipping the full SDK in the runtime image. Use multi-stage to copy only the compiled output.
- Running as root. Containers should run as non-root for security.
- Not stripping debug symbols in Go binaries.
-s -wremoves symbol and debug info, saving megabytes. - Forgetting to clean package caches.
apt-get,pip, andnpmall cache files that bloat the image.
Frequently Asked Questions
How much can multi-stage builds reduce image size?
A typical Node.js app with devDependencies can go from 900MB to 150MB (83 percent reduction). A Go binary can go from 1.2GB (golang base) to 15MB (scratch base), a 98 percent reduction.
Can I skip stages during build?
Yes. Use docker build --target builder to build only up to a specific stage. Useful for testing the build stage without creating the production image.
How do I debug a distroless image?
Distroless images have no shell. Use docker cp to copy a debugger in, or use a debug variant like gcr.io/distroless/python3-debian12:debug which includes a busybox shell.
Should I use Alpine or slim?
Alpine uses musl libc instead of glibc, which can cause issues with native modules (Python C extensions, Node.js native addons). If you hit compatibility problems, switch to slim (Debian-based).