Blog post image for Docker Multi-Stage Builds: Smaller, Safer Images for Production - Ship lean, secure containers by splitting your build from your runtime. This dev tip shows how Docker multi-stage builds drop the compilers and dev dependencies, so your production image is smaller and has far less to attack.

Docker Multi-Stage Builds: Smaller, Safer Images for Production

Published: Updated: 04 Mins read06 Mins listen

Why Multi-Stage Builds Matter?

Small images are not just about disk space

Hey, want to stop shipping a toolshed to production? If your Dockerfile builds and runs the app in one stage, your final image is carrying everything you used to build it: the compiler, the package manager caches, the dev dependencies, all of it. None of that runs in production, but all of it ships anyway. That means slower pulls, slower cold starts, and a lot more code that a scanner has to worry about. Multi-stage builds let you build with all your tools, then throw the tools away and keep only the finished app.

Build tools and runtime are different jobs

Building your app and running it need completely different things. The build needs compilers, headers, and dev packages. The runtime just needs your binary or bundled code and maybe a couple of shared libraries. Mixing the two into one image is the root of most bloated, insecure containers. Separating them is the whole idea here.

The Problem with Single-Stage Dockerfiles

What’s the issue?

A single-stage Dockerfile does everything in one place. You start from a full base image like node:20 or golang:1.22, install dependencies, compile or bundle, and that same fat image becomes what you deploy. So your production container includes gcc, npm, git, and every dev dependency you only needed for a few seconds during the build. You are shipping a build machine and calling it a runtime.

Real-world consequences

The costs pile up quietly. Images balloon to a gigabyte or more, which slows every deploy and every autoscale event. Worse, every extra package is another thing that can have a CVE. When your scanner flags fifty vulnerabilities, most of them are usually in build tools your app never even calls at runtime. You end up patching things that should not have been in the image in the first place.

The Solution: Multi-Stage Builds

Here’s how to fix it

A multi-stage build splits your Dockerfile into named stages using FROM ... AS. You do all the heavy work in a build stage, then start a fresh, minimal final stage and pull across only the artifact you need with COPY --from. Everything left behind in the build stage, the compilers and dev dependencies, never makes it into the image you ship.

Implementing it

Here is a Go example. The first stage compiles the binary, and the second stage starts from a tiny base and copies just that binary.

Dockerfile
# Build stage: has the full Go toolchain
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# Production stage: minimal, no compiler
FROM gcr.io/distroless/static
COPY --from=build /app /app
ENTRYPOINT ["/app"]

The final image has no Go toolchain, no shell, and no package manager. It is just your binary on a distroless base. The same pattern works for Node: build and bundle in a node:20 stage, then copy the dist folder and production node_modules into a slim runtime stage.

Tools and Platforms

BuildKit is the modern Docker builder and it runs independent stages in parallel and caches them well, so multi-stage builds stay fast. For final bases, reach for distroless images from Google or an alpine variant when you need a shell. Pair the build with a scanner like Trivy or Grype so you can see the vulnerability count drop after you strip the build tools out.

Quick Implementation Steps

Quick takeaways to slim down your images:

  • Split your Dockerfile into a build stage and a final stage with FROM ... AS.
  • Do all compiling and bundling in the build stage.
  • Start the final stage from a minimal base like distroless or alpine.
  • Copy only the finished artifact across with COPY --from.
  • Enable BuildKit so stages build in parallel and cache well.
  • Scan the final image and compare the size and CVE count before and after.

Copy only what runs

The trick is being strict about what crosses the COPY --from line. Bring over the compiled binary, the bundled assets, and production dependencies only. Leave source files, test files, and dev dependencies in the build stage where they belong. If you are not sure something is needed at runtime, it probably is not.

Measure the before and after

Run docker images to see the size difference, and run your scanner on both versions. It is not unusual to go from over a gigabyte down to under a hundred megabytes, with the vulnerability count dropping right along with it. That comparison is the easiest way to convince a team the change is worth it.

Benefits of Multi-Stage Builds

Why it helps?

You get a production image that only contains what it needs to run. That means faster pulls, faster cold starts, and a much smaller attack surface. With no compilers, package managers, or dev dependencies inside, there is simply less for an attacker to use and less for a scanner to flag.

Faster and safer deploys

Smaller images move faster through your whole pipeline. Registries store less, nodes pull quicker, and autoscaling responds sooner because there is less to download before a pod starts. On the security side, a distroless final stage with no shell makes a compromised container far harder to pivot from, since there is barely anything in there to run.

What’s Your Approach?

Community Discussion

What’s your take? Multi-stage builds are one of those changes that feel small but pay off every single deploy. If you have converted an old single-stage Dockerfile, how much smaller did the image get, and did your scan results improve?

Share Your Experience

If you are running multi-stage builds in production, I would love to hear which final base you settled on. Distroless, alpine, or something custom, and how you handle the cases where you still need a shell for debugging.

References

You might also enjoy

Check out some of our other posts on similar topics

ArgoCD GitOps: Sync Kubernetes Deployments Automatically from Git

ArgoCD GitOps: Sync Kubernetes Deployments Automatically from Git

Why GitOps for Kubernetes? From kubectl apply to Git as the source of truth Hey, want to stop deploying to Kubernetes by hand? If your releases still come from someone running `kubectl ap

Securing CI/CD with IAM Roles

Securing CI/CD with IAM Roles

Why Secure Your CI/CD Pipeline? The Importance of Pipeline Security Hey, want to keep your CI/CD pipeline safe? If you’re working on software, locking down your pipeline is a must. Using

Docker Is Eating Your Disk Space (And How PruneMate Fixes It)

Docker Is Eating Your Disk Space (And How PruneMate Fixes It)

The Problem: Docker Is Eating Your Disk Space Symptoms of Docker Disk Space Issues Your Docker host is running out of space. Again. You've been spinning up containers, testing new servic

Container Image Vulnerability Scanning in CI/CD with Trivy

Container Image Vulnerability Scanning in CI/CD with Trivy

Why Container Security Matters The Vulnerability Problem Container images are a critical attack surface in modern deployments. Every time you build a container image, it includes the bas

Kubernetes Namespaces: Organize, Isolate, and Secure Multi-Team Clusters

Kubernetes Namespaces: Organize, Isolate, and Secure Multi-Team Clusters

Why Cluster Isolation Matters The Multi-Tenant Reality Hey, want to share a single Kubernetes cluster across multiple teams without all the chaos? If you're running separate clusters for

5 related posts