Docker Multi-Stage Builds: Reduce Image Size and Harden Security
Docker multi-stage builds are essential for production. Learn how to reduce image size and improve container hardening by stripping build-time dependencies.
Last month, our team spent about two days debugging a bloated container that refused to deploy to our restricted Kubernetes cluster. The image was pushing 800MB because we were bundling source code, build tools, and testing dependencies that served no purpose once the app started.
When you're pushing to production, every extra megabyte is a liability. Using docker multi-stage builds allows you to separate the environment where you compile your code from the environment where you run it. By discarding the build-time artifacts, you significantly lower the attack surface and speed up your CD pipeline.
Why Multi-Stage Builds Are the Gold Standard
If you've been using a single Dockerfile with a massive base image like node:18 or python:3.11-slim, you're likely shipping unnecessary binaries. A compiler, a package manager, or even a shell can be used by an attacker if they manage to gain entry to your container.
We previously attempted to clean this up by using RUN rm -rf commands, but that only masks the issue; the files still exist in previous image layers. Multi-stage builds solve this by letting you define multiple FROM instructions in a single file. Only the final stage is kept, meaning the build tools never make it into your production registry.
Implementing Multi-Stage Docker Builds
Here is a common pattern I use for a Node.js application. Notice how we use a "builder" stage to install dependencies and run the build process, then copy only the necessary artifacts into a "production" stage.
Dockerfile# Stage 1: Build FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # Stage 2: Production FROM node:18-alpine WORKDIR /app # Copy only the compiled output and production dependencies COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules CMD ["node", "dist/server.js"]
This approach drastically trims your footprint. If you want to dive deeper into the basics of layer caching and build efficiency, I’ve covered those concepts in my guide on Docker Image Optimization: Reduce Build Size and Speed Up Deployments.
Strategies for Better Container Hardening
Once you've mastered the multi-stage structure, you should focus on container hardening. The most effective way to do this is by switching your final stage to a more minimal base, such as gcr.io/distroless/nodejs.
Distroless images contain only your application and its runtime dependencies. They do not contain package managers, shells, or any other programs you would expect to find in a standard Linux distribution.
| Feature | Standard Image | Distroless Image |
|---|---|---|
| Package Manager | Yes (apt/apk) | No |
| Shell Access | Yes | No |
| Attack Surface | Large | Minimal |
| Debugging | Easy | Requires sidecars |
A Note on Debugging
The trade-off is that docker exec -it <container> sh will stop working because there is no shell. This is a feature, not a bug. If you need to inspect a running container, use ephemeral debug containers (kubectl debug) rather than baking debugging tools into your production image.
Beyond the Basics: Advanced Optimization
If you're already using GitHub Actions, you can further optimize your workflow by combining these builds with remote caching. I’ve written about this integration in Docker optimization: Mastering Multi-Stage Builds and GitHub Actions.
When we moved our internal microservices to this multi-stage pattern, we saw our average image size drop from 750MB to roughly 120MB. This isn't just about saving space on a disk; it’s about reducing the time it takes for a node to pull the image and start the container during an auto-scaling event.
FAQ
Q: Do I really need to remove the shell from my container?
A: It's not strictly required, but it's a massive win for security. If an attacker gains remote code execution, they'll have a much harder time moving laterally or downloading secondary payloads if they can't run /bin/sh or curl.
Q: Are multi-stage builds slower?
A: Actually, they can be faster. Because Docker caches each stage individually, if you haven't changed your package.json, the builder stage will be skipped, and the cached layer will be used.
Q: What if I need to run integration tests during the build?
A: Add a test stage! You can run FROM builder AS tester and run your npm test there. If the tests fail, the build stops, and you never create the final production image.
I'm still tinkering with how to handle secrets during the build phase; for now, I rely on BuildKit secrets, but it’s a constant area of improvement. Remember that hardening is a process, not a destination. Start by stripping your images, then move toward non-root users and read-only filesystems.