Containers

Docker Multi-Stage Builds: Smaller Images, Faster Deployments

Build in one stage, copy to a minimal runtime image. Practical multi-stage Dockerfile examples for Go, Node.js, and Python that cut image sizes by 10x or more.

A
Abhishek Patel8 min read

Infrastructure engineer with 10+ years building production systems on AWS, GCP,…

Docker Multi-Stage Builds: Smaller Images, Faster Deployments
Docker Multi-Stage Builds: Smaller Images, Faster Deployments

Why Your Docker Images Are 10x Bigger Than They Need to Be

Docker multi-stage builds let you use one Dockerfile with multiple FROM statements -- build your application in a full-featured image, then copy just the compiled artifact into a minimal runtime image. The result is production images that are 10-50x smaller than naive single-stage builds, with zero build tools, zero dev dependencies, and a dramatically reduced attack surface.

I've seen teams shipping 1.2 GB Node.js images that could be 120 MB, and 800 MB Go images that should be 12 MB. Multi-stage builds fix this with no changes to your build process -- just a smarter Dockerfile.

What Is a Docker Multi-Stage Build?

Definition: A Docker multi-stage build is a Dockerfile pattern that uses multiple FROM instructions to create separate build stages. Each stage can use a different base image. Only the final stage becomes the output image, and you selectively copy artifacts from earlier stages using COPY --from.

Before multi-stage builds existed (pre-Docker 17.05), people either shipped bloated images with compilers and build tools, or maintained separate Dockerfiles and shell scripts to chain builds together. Multi-stage builds solved both problems in a single file.

How Multi-Stage Builds Work: Step by Step

  1. Define a build stage with a full SDK or compiler image (e.g., golang:1.22, node:20)
  2. Install dependencies and compile your application inside that stage
  3. Start a new stage with a minimal runtime image (e.g., alpine, distroless, scratch)
  4. Copy the built artifact from the build stage using COPY --from=builder
  5. Docker discards all intermediate stages -- only the final stage becomes your image

Example: Go Binary (From 800 MB to 12 MB)

Go is the poster child for multi-stage builds because it compiles to a static binary with no runtime dependencies.

# Stage 1: Build
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 -ldflags="-s -w" -o /app/server ./cmd/server

# Stage 2: Runtime
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]
ApproachBase ImageFinal Size
Single stage (golang:1.22)golang:1.22~820 MB
Multi-stage (alpine)alpine:3.19~18 MB
Multi-stage (scratch)scratch~12 MB

Pro tip: Use -ldflags="-s -w" when building Go binaries for containers. The -s flag strips the symbol table and -w strips DWARF debug info. This typically saves 30-40% on binary size with no runtime impact.

Example: Node.js Application (From 1.1 GB to 150 MB)

Node.js doesn't compile to a binary, so the strategy is different: install all dependencies in the build stage, run the build step, then copy only production dependencies and built assets to the runtime stage.

# Stage 1: Install and build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

# Stage 2: Production
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile --prod
COPY --from=builder /app/dist ./dist

EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]

Watch out: Don't copy node_modules from the build stage. The build stage has dev dependencies (TypeScript, ESLint, test frameworks) that you don't want in production. Instead, run a fresh pnpm install --prod in the runtime stage to get only production dependencies.

Example: Python Application (From 900 MB to 80 MB)

Python multi-stage builds work well when you have compiled dependencies (C extensions, Cython, etc.):

# Stage 1: Build wheels
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir build wheel
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

# Stage 2: Runtime
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* && rm -rf /wheels
COPY . .

USER nobody
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0"]

Named Stages and Stage Targeting

The AS keyword gives stages meaningful names. You can also build up to a specific stage using --target, which is powerful for development workflows:

# Build only the builder stage (useful for CI caching)
docker build --target builder -t my-app:build .

# Build the full production image
docker build -t my-app:latest .

Referencing External Images as Stages

You can copy from any image, not just previous stages in the same Dockerfile:

# Copy nginx config from the official image
COPY --from=nginx:alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf

# Copy a binary from a published tool image
COPY --from=aquasec/trivy:latest /usr/local/bin/trivy /usr/local/bin/trivy

Cache Invalidation and Layer Ordering

Layer caching is critical for fast builds. Docker caches each layer and reuses it if the inputs haven't changed. The key rule: put things that change rarely at the top, things that change often at the bottom.

Optimal Layer Order for Node.js

  1. Base image -- changes almost never
  2. Package manifest (package.json, lockfile) -- changes when dependencies update
  3. Dependency install -- cached until the manifest changes
  4. Source code copy -- changes on every commit
  5. Build step -- runs every time source changes
# GOOD: Dependencies cached separately from source
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

# BAD: Any source change invalidates dependency cache
COPY . .
RUN pnpm install && pnpm build

Pro tip: Use a .dockerignore file aggressively. Exclude node_modules, .git, dist, test files, and documentation. Every file in the build context is sent to the Docker daemon, and unnecessary files bust the cache and slow builds.

Size Comparison: Base Image Options

Base ImageSizePackage ManagerShellBest For
ubuntu:22.0477 MBaptbashApps needing glibc + many system libs
debian:bookworm-slim74 MBaptbashSlim Debian with essential packages
alpine:3.197 MBapkashSmall images, musl-compatible apps
gcr.io/distroless/static2 MBNoneNoneStatic binaries (Go, Rust)
scratch0 MBNoneNoneAbsolute minimum, static binaries only

Pricing and CI/CD Cost Impact

Smaller images directly reduce infrastructure costs:

  • Registry storage -- Docker Hub free tier gives 1 public repo. Pro ($5/month) gives unlimited private repos. Smaller images mean less storage and faster pulls.
  • CI build minutes -- GitHub Actions charges $0.008/min for Linux runners. Layer caching with multi-stage builds typically cuts build times by 40-60%.
  • Network transfer -- AWS ECR charges $0.09/GB for data transfer. A 1 GB image pulled 1000 times/month costs $90; a 100 MB image costs $9.
  • Cold start time -- serverless container platforms (AWS Fargate, Cloud Run) charge while pulling images. Smaller images = faster starts = lower bills.

Frequently Asked Questions

Do multi-stage builds make the build process slower?

No. The total build time is roughly the same because you're doing the same work -- compiling, installing dependencies. The difference is the final image is smaller because intermediate layers (compilers, dev tools) are discarded. With proper layer caching, multi-stage builds can actually be faster because cached layers are smaller.

Can I have more than two stages in a Dockerfile?

Yes. You can have as many stages as you need. Common patterns include a dependencies stage, a build stage, a test stage, and a final runtime stage. Only the last FROM (or the one targeted with --target) becomes the output image.

What is the difference between scratch and distroless?

scratch is a completely empty image -- zero bytes, no files. Distroless images from Google contain minimal OS libraries (CA certificates, timezone data, glibc) but no shell, package manager, or other tools. Use scratch for fully static binaries; use distroless when your binary needs system libraries.

Should I use Alpine or Debian slim as my runtime base?

Alpine is smaller (7 MB vs 74 MB) but uses musl libc instead of glibc. Most applications work fine on Alpine, but some compiled dependencies (especially Python C extensions) may have compatibility issues with musl. If you hit strange runtime errors, switch to Debian slim. Otherwise, Alpine is the better default.

How do I debug a multi-stage build if something fails?

Use docker build --target builder to build up to a specific stage, then run a shell in that image: docker run -it --rm my-app:build sh. You can inspect the filesystem, check installed packages, and verify build output. This is much easier than debugging a single monolithic build.

Do all layers from previous stages get included in the final image?

No. That's the entire point. Only the layers from the final stage (and any files explicitly copied with COPY --from) end up in the output image. All intermediate stage layers are used during the build and then discarded. This is why the final image is so much smaller.

Conclusion

Multi-stage builds should be your default for every Dockerfile. The pattern is simple: build in a full image, copy to a minimal one. Start with the examples above for your language, add a .dockerignore, order your layers for caching, and you'll have images that are faster to push, faster to pull, and harder to exploit. There's no good reason to ship a compiler to production.

A

Written by

Abhishek Patel

Infrastructure engineer with 10+ years building production systems on AWS, GCP, and bare metal. Writes practical guides on cloud architecture, containers, networking, and Linux for developers who want to understand how things actually work under the hood.

Related Articles

Enjoyed this article?

Get more like this in your inbox. No spam, unsubscribe anytime.

Comments

Loading comments...

Leave a comment

Stay in the loop

New articles delivered to your inbox. No spam.