Your Docker images are probably too big. Most applications ship with compilers, package managers, and debug tools that never run in production. Multi-stage builds fix this by separating your build environment from your runtime environment.
The Problem with Single-Stage Builds
Here’s a typical Node.js Dockerfile:
| |
This works, but the resulting image includes:
- The full Node.js development environment
- npm and all its dependencies
- Your
node_moduleswith dev dependencies - TypeScript compiler (if you’re building TS)
- Source files you don’t need at runtime
Result: A 1.2GB image for a 50MB application.
Multi-Stage to the Rescue
Multi-stage builds let you use multiple FROM statements. Each FROM starts a new build stage, and you can selectively copy artifacts between stages:
| |
The node:20-slim image is 200MB vs 1.1GB for the full image. But we can go further.
Going Minimal with Distroless
Google’s distroless images contain only your application and its runtime dependencies. No shell, no package manager, no attack surface:
| |
This three-stage approach:
- Builds your TypeScript/JavaScript
- Creates a clean production
node_modules - Copies only what’s needed into a minimal runtime
Result: ~150MB image with no shell access for attackers.
Go Applications: The Ultimate Slim
Go’s static compilation makes for even smaller images:
| |
The scratch image is literally empty — 0 bytes. Your final image is just your binary plus SSL certificates. A typical Go API server comes in under 15MB.
The -ldflags="-s -w" strips debug symbols, saving another 30% on binary size.
Python with Virtual Environments
Python multi-stage builds are trickier because of dynamic linking, but still worthwhile:
| |
The virtual environment isolates your dependencies, and copying the whole /opt/venv directory preserves all installed packages.
Build Arguments for Flexibility
Make your multi-stage builds configurable:
| |
Build for different targets:
| |
Caching Strategies
Layer ordering matters for cache efficiency. Put rarely-changing layers first:
| |
For monorepos, use .dockerignore aggressively:
Security Benefits
Multi-stage builds improve security by:
- Reducing attack surface — No compiler means no compiling malware
- Removing secrets — Build-time secrets don’t persist to runtime
- Eliminating tools — No
curl,wget, or shell in distroless images
For secrets during build, use BuildKit:
| |
Build with:
| |
The secret is available during build but never persists in any layer.
Measuring the Impact
Before and after on a real Node.js API:
| Approach | Image Size | Build Time |
|---|---|---|
| Single-stage node:20 | 1.24 GB | 45s |
| Multi-stage node:20-slim | 287 MB | 48s |
| Multi-stage distroless | 152 MB | 51s |
The 3-6 second build overhead pays for itself in:
- Faster image pulls during deployment
- Reduced storage costs
- Smaller attack surface
- Faster container startup
Start Small
You don’t need to go full distroless on day one. Start by:
- Switching to
-slimor-alpinebase images - Separating build and runtime stages
- Using
.dockerignoreto exclude junk
Then iterate toward smaller, more secure images as your CI/CD matures.
Your containers should contain your application, not your entire development environment.