Your Docker images are probably too big. Mine were. Then I learned about multi-stage builds.
The problem is simple: build tools bloat production images. You need Node.js and npm to build your React app, but you only need nginx to serve it. You need Go and its toolchain to compile, but the binary runs standalone. Every megabyte of build tooling in your production image is wasted space, slower deploys, and expanded attack surface.
Multi-stage builds solve this elegantly.
The Basic Pattern
| |
Two FROM statements. The first stage (builder) has all the build tools. The second stage starts fresh and copies only what’s needed — the built artifacts.
Result: your production image has nginx and your static files. No node_modules. No npm. No source code.
Go Applications
Go shines here because it compiles to a single binary:
| |
FROM scratch means literally nothing — an empty filesystem. Your final image contains only your binary and SSL certificates. A typical Go API server: 10-15MB total.
The -ldflags="-s -w" strips debug symbols, shaving off a few more megabytes.
Python with Virtual Environments
Python’s trickier because you need the interpreter at runtime, but you can still separate build dependencies:
| |
Build dependencies (poetry, compilers for native extensions) stay in the builder. Production gets only the virtual environment with runtime packages.
Rust: The Extreme Case
Rust takes longer to compile but produces tiny images:
| |
A web server in 5MB. No runtime, no interpreter, no libraries. Just your code.
Caching Dependencies
The key optimization: copy dependency manifests first, install, then copy source. Docker caches layers, so unchanged dependencies skip the slow install step:
| |
Order matters. Put the slowest, least-frequently-changing steps first.
Multiple Named Stages
You can have more than two stages, and reference any of them:
| |
Run tests with docker build --target tester . — it stops at that stage. Build production with --target production or just let it run to the end.
Common Gotchas
Copying from the wrong stage:
| |
Missing runtime dependencies: If your app needs shared libraries at runtime (SSL, image processing), you need them in the final image too:
| |
File permissions: Files copied between stages keep their permissions. If you need specific ownership:
| |
The Payoff
Before multi-stage builds, my Node.js app image was 1.2GB (node:18 base + node_modules + build tools). After: 45MB (nginx:alpine + static files).
Deploys went from 90 seconds to 8 seconds. Image pulls that used to timeout on slow connections just work now.
Smaller images aren’t just about disk space. They’re faster to push, pull, and start. They have fewer packages to patch. They’re easier to scan for vulnerabilities because there’s less to scan.
Multi-stage builds are the single highest-impact Docker optimization I know. If you’re not using them, start today.
Computing Arts explores the craft of building with code and infrastructure. More at computingarts.com.