
Reducing Container Image Size with Multi-Stage Builds
Reducing your production image size directly affects deployment speed and registry costs.
This post covers how to use multi-stage Docker builds to strip away build-time dependencies, leaving only the minimal runtime required for your application. You'll learn to separate the compilation environment from the execution environment, which keeps your final artifacts small and secure.
When you build a modern application—whether it's a Go binary, a React app, or a Python service—the tools needed to compile or transpile that code are rarely needed to run it. If you use a single-stage Dockerfile, you're shipping compilers, package managers, and source code files inside your production image. This isn't just an efficiency problem; it's a security risk. A larger attack surface means more vulnerabilities for hackers to exploit. By using multi-stage builds, you ensure that your production container only contains the bare essentials.
Why does image size matter for CI/CD?
Every time your deployment pipeline triggers a new build, that image has to be pushed to a registry and pulled by your orchestration layer (like Kubernetes or ECS). If your image is 1.5GB because you left a heavy build environment inside, you're wasting bandwidth and increasing your deployment latency. Smaller images move faster. In a scaling event where your cluster needs to spin up twenty new pods to handle a traffic spike, those extra hundreds of megabytes per pull can mean the difference between a smooth transition and a service outage.
Think about the way layers work in Docker. Each instruction in a Dockerfile creates a new layer. If you install a heavy dependency in one layer and delete it in the next, that dependency is still part of the image history and takes up space. Multi-stage builds solve this by letting you start a fresh stage with a clean slate. You copy only the specific artifacts you want from the previous stage, effectively discarding the entire history of the build process.
How do I implement a multi-stage build in a Dockerfile?
The syntax is straightforward. You use the FROM instruction multiple times. The first stage handles the heavy lifting, and the final stage serves the application. Here is a practical example using a Node.js environment:
# Stage 1: Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Production stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/main.js"]
In this example, the builder stage contains the full Node.js environment and all the dev-dependencies. The final stage only copies the dist folder and the production-ready node_modules. This keeps the final image lean. If you want to see more about official best practices, check out the Docker documentation on multi-stage builds.
What are the security benefits of smaller images?
Beyond the speed and storage benefits, there's a massive security advantage. A standard Ubuntu or Debian image contains hundreds of binaries and libraries that your application likely never touches. Each one of those is a potential entry point. By using a minimal base image like alpine or a Distroless image, you've removed the shells, the package managers, and the utilities that an attacker would use to move laterally through your network after an initial breach.
For instance, if an attacker gains execution rights through a vulnerability in your code, they'll look for curl, wget, or apt to download more malicious tools. If your production image doesn't have those tools, their job becomes much harder. You can learn more about container security standards through MITRE CVE to understand why reducing your footprint is a standard industry practice.
When you're designing these workflows, remember that the goal is to keep the production environment as sterile as possible. You aren't just optimizing for disk space; you're optimizing for a predictable, minimal runtime. This means your local development environment can be heavy and feature-rich, while your production environment stays lean and mean. It's a separation of concerns that benefits both the developer experience and the operational reliability of your system.
As you refine your build process, watch your image sizes closely. If you see a sudden jump in size after adding a new dependency, check if that dependency is being included in your final stage. Often, a single forgotten COPY command or a wide-reaching glob pattern can undo all your hard work. Keep your build logic tight, and your production images will reflect that discipline.
