Back to Blog
DevOpsJune 30, 20264 min read

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.

dockerdevopscontainer-securityoptimizationlinux

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.

FeatureStandard ImageDistroless Image
Package ManagerYes (apt/apk)No
Shell AccessYesNo
Attack SurfaceLargeMinimal
DebuggingEasyRequires 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.

Similar Posts