Docker images can be surprisingly bloated, and multi-stage builds are the secret weapon for slimming them down.

Let’s see this in action with a simple Go application.

Here’s a main.go file:

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello from a tiny Go app!")
	})
	fmt.Println("Server starting on port 8080...")
	http.ListenAndServe(":8080", nil)
}

And a Dockerfile for a traditional, single-stage build:

FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY main.go .
RUN go build -o /app/main .

FROM alpine:latest
COPY --from=builder /app/main /app/main
CMD ["/app/main"]

Before multi-stage builds, you’d likely do something like this. The builder stage compiles the Go app using a Go image, which has all the Go SDK tools. Then, you’d copy just the compiled binary into a new, minimal alpine image. This is already a step up!

Now, let’s look at the multi-stage version:

# Stage 1: Build the Go application
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY main.go .
RUN go build -o /app/main .

# Stage 2: Create the final, minimal image
FROM alpine:latest
COPY --from=builder /app/main /app/main
CMD ["/app/main"]

Notice how the Dockerfile is structured. We have two FROM instructions. The first FROM golang:1.21-alpine AS builder defines our first stage, named builder. This stage uses a Go image to compile our application. The second FROM alpine:latest starts a new, completely separate stage. The magic happens with COPY --from=builder /app/main /app/main. This command copies only the compiled main executable from the builder stage into the final alpine image.

The key insight here is that the intermediate build environment (the Go SDK, source code, build artifacts that aren’t the final binary) is entirely discarded when the final image is created. You’re not layering a build environment on top of your runtime environment. You’re creating two distinct environments and only transferring the essential output from one to the other.

Let’s build and compare.

First, the single-stage (which is already better than a monolithic image):

docker build -t my-go-app-single-stage -f Dockerfile.single .
docker inspect my-go-app-single-stage | grep Size
# Output will show a size, e.g., "Size": 30000000,

Now, the multi-stage version (using the Dockerfile above):

docker build -t my-go-app-multi-stage .
docker inspect my-go-app-multi-stage | grep Size
# Output will show a significantly smaller size, e.g., "Size": 15000000,

The multi-stage build results in a much smaller image because it doesn’t include the Go compiler, source code, or any other build dependencies in the final runtime image. The final image only contains the minimal Alpine Linux base and your compiled Go binary. This is critical for faster deployments, reduced storage costs, and improved security (fewer components mean a smaller attack surface).

The builder stage in the Dockerfile is effectively a temporary workspace. You can have as many stages as you need, each with its own FROM instruction. You can even copy artifacts from intermediate stages other than just the immediately preceding one by referencing their stage names (e.g., COPY --from=stage_name /path/in/stage /path/in/final). This is incredibly powerful for complex build processes, like compiling assets in one stage and then copying them to a runtime stage that might use a different language or web server.

The most surprising thing about multi-stage builds is that the intermediate stages are completely ephemeral by default. You don’t need to explicitly clean them up. Docker only keeps the layers necessary for the final image specified by the last FROM instruction. If you want to inspect or use an intermediate stage, you can build specific targets using docker build --target builder -t my-builder-stage ..

This technique fundamentally changes how you think about building containerized applications, moving from monolithic images to highly optimized, purpose-built runtime environments.

The next logical step is to explore how to further optimize these minimal images by using even smaller base images or by leveraging build arguments for more dynamic builds.

Want structured learning?

Take the full Docker course →