Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 3

Streamlining Docker Images with Multi-Stage Builds

Recommended resources

Hello! Welcome to the third lesson in our "Production-Grade Dockerfiles" module.

In our last two lessons, we focused on optimizing the Docker build process. First, we cleaned up the build context with .dockerignore, and then we leveraged the build cache by carefully ordering our Dockerfile instructions. These steps make rebuilding your images faster. Today, we'll take the next logical step and optimize the final image itself using multi-stage builds.

By the end of this lesson, you will be able to implement multi-stage builds to create lean, secure, production-ready images by separating the tools needed to build your application from the environment needed to run it.

1. The Problem: Bloated Single-Stage Images

In a typical software project, you have two distinct sets of dependencies:

  • Build-time dependencies: Compilers, SDKs, testing frameworks, linters, and development libraries needed to compile, test, and package your code. (e.g., the Go compiler, the Java JDK, devDependencies in Node.js).
  • Run-time dependencies: The minimal set of libraries and binaries required for the application to execute in production. (e.g., a compiled binary, a Java JRE, only dependencies in Node.js).

A naive approach is to use a single Dockerfile that installs everything, builds the app, and then uses the resulting image for production. This leads to what are often called "fat" or "bloated" images.

Think of it like building furniture. The build process requires a workshop full of tools: saws, drills, sanders, and raw materials. The final product is just the piece of furniture. A single-stage build is like shipping the finished table along with the entire workshop inside the same box.

This approach has two major drawbacks that are critical in a professional DevOps context:

  1. Increased Size: The final image is unnecessarily large, leading to slower push/pull times in CI/CD pipelines, increased network transfer costs, and higher container registry storage usage.
  2. Increased Attack Surface: The image contains many tools (like gcc, curl, git) that are not needed at runtime. Every extra package is a potential source of security vulnerabilities (CVEs) that an attacker could exploit.

2. The Solution: Multi-Stage Builds

Docker's multi-stage build feature allows you to use multiple FROM statements within a single Dockerfile. Each FROM instruction begins a new "stage" of the build, which you can think of as a temporary, intermediate image. You can then selectively copy artifacts from one stage to another, discarding everything you don't need.

Multi-Stage Docker Build Illustration
This image visually represents the core concept of a multi-stage build. A "build stage" is used to create an artifact, which is then copied into a clean "production ready stage", resulting in a much smaller final image.

A typical multi-stage Dockerfile has at least two stages:

  1. The builder stage: Starts from a full-featured base image containing the complete SDK (e.g., golang:1.21, maven:3-jdk-17). This stage copies the source code, installs all dependencies, runs tests, and compiles the application to produce the final executable artifact.
  2. The final stage: Starts from a minimal base image (e.g., alpine, scratch, or a distroless image). This stage uses the COPY --from=<stage_name> ... instruction to copy only the necessary artifact(s) from the builder stage.

Let's watch a video that explains this concept and then demonstrates it with a hands-on example using a Go application. The size reduction is dramatic and clearly shows the power of this technique.

Day-26 | Multi Stage Docker Builds | Reduce Image Size by 800 % | Distroless Container Images | #k8s

This video from Abhishek.Veeramalla, 'Multi Stage Docker Builds', provides an excellent explanation of the problem with single-stage builds and then walks through a practical multi-stage build for a Go application.

Please watch the following segments: The Problem (01:35 - 04:54): Understand the issue with bloated single-stage images. The Concept (04:54 - 08:54): Get an introduction to the multi-stage build syntax and logic. Hands-on Demo (19:15 - 29:39): Watch the full walkthrough. The key parts are building the single-stage image and noting its size (~861 MB), then building the multi-stage version and seeing the final size (~1.8 MB).

As you saw in the video, the result is a massive improvement. We went from an ~861 MB image containing the entire Go toolchain to a tiny ~1.8 MB image containing only the compiled, self-contained application binary.


3. Deconstructing the Multi-Stage Dockerfile

Let's analyze the structure used in the video for the Go application.

# STAGE 1: The "builder" stage
# We give this stage an alias "build" using "AS build"
FROM golang:1.21 AS build
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/calculator .

# STAGE 2: The "final" stage
# This stage starts from a completely new, empty base image
FROM scratch
WORKDIR /
# We copy ONLY the compiled binary from the "build" stage
COPY --from=build /app/calculator /calculator
ENTRYPOINT ["/calculator"]

Key things to note:

  • FROM ... AS build: We use AS to give the first stage a name, build. This makes it easy to reference later.
  • COPY --from=build ...: This is the magic instruction. It tells Docker to copy the file /app/calculator from the stage named build into the current stage.
  • Final Image: When you run docker build, by default, only the last stage becomes the final tagged image. All intermediate stages and their layers are discarded (unless referenced by the final stage), saving space.
Test your understanding!

You have a Node.js web application. The builder stage has run npm install and npm run build, creating a static site in the /app/dist directory.

How would you write the final stage of your Dockerfile to serve these static files using a minimal nginx web server? Assume your builder stage is named builder.

Show answer
# STAGE 2: The "final" stage
FROM nginx:1.25-alpine

# Copy the static assets from the 'builder' stage into the Nginx web root
COPY --from=builder /app/dist /usr/share/nginx/html

# Nginx will automatically serve files from this directory.
# No CMD or ENTRYPOINT needed as the base image has one.

This is a very common pattern for single-page applications (SPAs) built with frameworks like React, Vue, or Angular.


4. Choosing Your Final Base Image

The final stage's base image has a huge impact on size and security. Here are your main options, from most to least minimal:

  • scratch: A special, empty image. It is the most minimal and secure option possible. It's perfect for statically compiled binaries (like those from Go with CGO_ENABLED=0) that have zero operating system dependencies. It has no shell or any other user-space tools.
  • Distroless Images: Created by Google, these images contain only an application and its runtime dependencies. They do not contain package managers, shells, or other standard utilities. This drastically reduces the attack surface while still providing necessary runtimes (e.g., for Java, Python, or non-static Go binaries).
  • Alpine (-alpine tags): A very popular choice. Alpine Linux is a minimal distribution that includes a package manager (apk) and a shell. It's a great option when you need a tiny OS but might still need to install a few essential runtime-only system libraries (e.g., ca-certificates).

The video you watched earlier also has a great segment on Distroless images and how to find them.

Day-26 | Multi Stage Docker Builds | Reduce Image Size by 800 % | Distroless Container Images | #k8s

Let's revisit the same video to learn more about Distroless images and their security benefits.

Please watch these two short segments: What are Distroless Images? (14:27 - 19:15): This explains their minimalistic nature and security advantages. How to Find Distroless Images (29:39 - 31:30): Shows you where to find the official Google Distroless images for your projects.


5. Advanced Multi-Stage Patterns

Multi-stage builds are more than just a two-step process. Here are some advanced patterns that are extremely useful in a DevOps workflow.

Targeting a Specific Stage

By default, docker build builds the last stage. However, you can tell it to stop at any named stage using the --target flag.

docker build --target builder -t my-app-builder .

This is useful for:

  • Debugging: If your build fails, you can build an intermediate stage and run a container from it to inspect the filesystem and figure out what went wrong.
  • Development vs. Production Images: You can have a development stage with debugging tools and a production stage that's minimal, all from the same Dockerfile.

Copying Artifacts from External Images

The COPY --from instruction isn't limited to stages in your current Dockerfile. You can pull artifacts directly from any other Docker image, either local or from a registry. This is a powerful way to compose tooling.

For a deeper dive into these advanced patterns and to see how multi-stage builds apply to a wide variety of programming languages, the following article is an excellent, comprehensive resource.

How to Build Smaller Container Images: Docker Multi-Stage Builds

The article 'How to Build Smaller Container Images: Docker Multi-Stage Builds' from iximiuz Labs is a fantastic reference. It covers the history of this pattern and provides high-quality examples for Node.js, Python, Java, Rust, and more.

You don't need to read this whole article now, but I highly recommend bookmarking it. For this lesson, please focus on these parts: Skim 'How lean images were produced before Multi-Stage Builds' to appreciate the 'Builder Pattern' that multi-stage builds replaced. Read 'An easy way to understand Multi-Stage Builds' and 'The power of Multi-Stage Dockerfiles' for a great conceptual breakdown. Look at the 'Multi-Stage Builds in practice' section and find the examples for the languages you work with most often (e.g., Node.js, Python, Java). Notice how the pattern is similar across stacks.


Conclusion

Today you've learned one of the most impactful techniques for creating professional, production-grade Docker images. By separating build concerns from runtime concerns, multi-stage builds allow you to produce final images that are dramatically smaller and more secure.

Key Takeaways:

  • Single-stage builds result in bloated images with a large attack surface because they bundle build-time tools with the final application.
  • Multi-stage builds use multiple FROM statements in one Dockerfile to create separate build environments.
  • The COPY --from=<stage_name> instruction is the key to transferring only the necessary artifacts (like compiled binaries or static assets) to the final stage.
  • The final image is based on the last stage in the Dockerfile by default.
  • Choosing a minimal final base image like scratch, a distroless image, or alpine is crucial for maximizing size and security benefits.
  • Advanced patterns like targeting specific stages (--target) enable flexible development and debugging workflows.

In our next lesson, we'll cover parameterizing Docker builds using build arguments (ARG). This will give us a way to pass variables from the command line into our Dockerfile, making our builds more dynamic and reusable—for example, to pass in version numbers or environment-specific settings.