Multi-stage builds in Docker are the secret weapon for shipping leaner, more secure containerized applications, and they do it by fundamentally changing how your build environment relates to your runtime environment.

Imagine you’re building a Go application. Your Dockerfile might look something like this to start:

FROM golang:1.20 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp .

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]

Let’s break this down. The first FROM golang:1.20 AS builder line pulls down the official Go image, which is quite large because it contains the Go compiler, SDK, and all its dependencies. We tag this stage builder. Inside this stage, we copy our application’s source code, COPY . ., and then compile it using RUN go build -o myapp .. This creates a static binary of our application.

Now, here’s where the magic of multi-stage builds really shines. The second FROM alpine:latest line starts a completely new, independent build stage. We’re using alpine:latest here, which is a tiny Linux distribution, drastically smaller than the Go image. We then use COPY --from=builder /app/myapp . to selectively copy only the compiled myapp binary from the builder stage into this new, minimal stage. Finally, CMD ["./myapp"] sets the command to run our application when the container starts.

The result? A final Docker image that contains only the compiled binary and its runtime dependencies, not the entire Go SDK or build tools. This drastically reduces the image size. For our Go example, the difference between an image built with just golang:1.20 and one using a multi-stage build with alpine can be hundreds of megabytes.

Why is this a big deal?

  • Smaller Images, Faster Deployments: Less data to download means faster pull times on your servers, quicker deployments, and less bandwidth consumed.
  • Enhanced Security: By excluding build tools, compilers, and development libraries from the final image, you shrink the attack surface. If an attacker gains access to your running container, they won’t find a full-blown development environment with tools like gcc, make, or even the Go compiler. This is a critical security best practice.
  • Cleaner Build Process: Your build environment is isolated. You can use heavy, complex build tools in an early stage without them ever contaminating your production image.

Let’s look at another common scenario: Node.js applications. A typical Dockerfile without multi-stage builds might look like this:

FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "start"]

This image will contain the Node.js runtime, npm, and all your development dependencies installed via npm install. If you have many dev dependencies (like linters, test runners, bundlers), they can add significant bloat.

With a multi-stage build, we can separate the dependency installation and build process (if you’re using a bundler like Webpack or Parcel) from the final runtime image:

FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --production=false # Install ALL dependencies, including dev
COPY . .
RUN npm run build # This might use dev dependencies like Webpack

FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist # Copy only built artifacts
COPY --from=builder /app/node_modules ./node_modules # If needed for runtime, but often not
COPY --from=builder /app/package.json ./ # If needed for runtime
CMD ["node", "dist/server.js"] # Or whatever your build output is

In this Node.js example, the builder stage installs all dependencies (including dev dependencies needed for npm run build). The final stage, using node:18-alpine (a smaller variant), copies only the necessary built artifacts (e.g., the dist folder) and production dependencies from the builder stage. This keeps the final image lean.

The key is understanding that each FROM instruction in a Dockerfile starts a new, independent build stage. You can have as many stages as you need. You can even copy artifacts from intermediate stages that aren’t the immediate predecessor.

When you build this Dockerfile using docker build -t myapp ., Docker executes each stage sequentially. However, the final image is composed only of the layers from the last stage, unless you explicitly copy from earlier stages.

One subtle but powerful aspect is how you can leverage different base images for different stages. For example, you might use a Debian-based image for building if you have complex C dependencies that are easier to manage there, and then copy the compiled artifacts into a minimal scratch or alpine image for runtime.

The final output of docker build is only the last stage. If you want to inspect an intermediate stage, you can build it explicitly using docker build --target builder -t myapp-builder .. This is invaluable for debugging your build process.

The next logical step after mastering multi-stage builds is to explore techniques for further optimizing image layers and understanding how Docker’s layer caching works to speed up your build workflows even more.

Want structured learning?

Take the full DevOps & Platform Engineering course →