Introduction
Docker makes it easy to package an application and its dependencies into a container image. However, the image used to build an application is not necessarily the image that should run it in production.
Building an application often requires compilers, SDKs, package managers, development dependencies, and source code. Once the application has been built, many of these components are no longer required. If they remain in the final Docker image, they increase its size and add unnecessary components to the production environment.
This is where Docker multi-stage builds provide a practical solution.
Multi-stage builds allow you to separate the build environment from the runtime environment within the same Dockerfile. The application can be built in one stage using all the required tools and dependencies, while the final stage contains only the artifacts and runtime dependencies needed to run the application.
This approach can produce significantly smaller Docker images, faster deployments, and a more minimal production environment.
In this article, we will examine how Docker images become unnecessarily large, how multi-stage builds solve this problem, how to implement them in a Dockerfile, and how to optimize the resulting image for production.
1. Why Docker Images Get Large
A Docker image can contain much more than the application that ultimately runs inside the container.
Consider a typical application build process. Before the application can run, it may require a compiler, SDK, development libraries, package managers, source code, and other build-time dependencies. These components are necessary to create the application artifact, but they usually have no role once that artifact is ready for production.
When the build and runtime environments are combined into a single Docker image, all of these components can remain in the final image.
The result looks like this:
Build Tools + Build Dependencies + Source Code + Application + Runtime Dependencies
But the production container often needs only:
Application + Runtime Dependencies
This difference is the main reason Docker images can become unnecessarily large.
Why Docker Image Size Matters
A larger image affects more than storage. Docker images are frequently pushed to container registries and pulled by CI/CD systems, servers, and Kubernetes nodes. The larger the image, the more data needs to be transferred during these operations.
Reducing Docker image size can therefore help with:
- Faster image pushes and pulls
- Faster deployments
- Lower storage and registry usage
- Smaller production environments
- Reduced attack surface by excluding unnecessary build tools and packages
The underlying problem is straightforward: the environment required to build an application is often much larger than the environment required to run it.
Multi-stage builds address this by keeping the build environment separate from the final runtime image.
2. The Problem with a Single-Stage Dockerfile
A traditional Dockerfile typically uses a single base image to both build and run the application. All build tools, dependencies, source files, and runtime components are created within the same image.
For example, a compiled application may require a compiler and development dependencies during the build process. With a single-stage Dockerfile, those components remain part of the resulting image even though they are not required to run the application.
The workflow becomes:
Source Code → Build Tools → Build Dependencies → Application → Final Image
The problem is that the final image contains both the build environment and the runtime environment.
What Gets Included
A single-stage image can contain components such as:
- Compilers and SDKs
- Development libraries
- Package managers
- Source code
- Build caches and temporary files
- Development dependencies
- The final application
Most of these components are useful only while building the application. Once the application has been compiled or packaged, they provide no value to the production container.
Why This Approach Is Inefficient
Using one image for both build and runtime creates an unnecessarily large production image. Every copy of that image pushed to a registry, stored in a cache, or pulled by a deployment environment carries those build-time components with it.
More importantly, the production environment becomes dependent on components that should never need to exist there.
The better approach is to separate the two responsibilities:
Build Stage: Contains everything required to build the application.
Runtime Stage: Contains only what is required to run the finished application.
This separation is the fundamental idea behind Docker multi-stage builds.
3. The Idea Behind Multi-Stage Builds
Docker multi-stage builds solve the problem by separating the build environment from the runtime environment.
Instead of creating the entire application inside one image, a Dockerfile can contain multiple stages. Each stage can have its own base image, dependencies, and purpose.
The first stage is typically responsible for building the application. It can contain compilers, SDKs, package managers, development dependencies, and source code without worrying about their presence in the final production image.
The final stage starts with a clean runtime image and copies only the artifacts required to run the application from the build stage.
The workflow becomes:
Source Code → Build Stage → Build Artifact → Runtime Stage → Final Image
For example, a compiled application might use a large image containing the compiler and development tools during the build.
Build Stage
Compiler + SDK + Dependencies + Source Code → Application Binary
The runtime stage then takes only the resulting binary.
Runtime Stage
Minimal Base Image + Application Binary → Production Image
The build environment is therefore not carried into the final image. Only the files explicitly copied from the earlier stage become part of the resulting production image.
This separation is what allows multi-stage builds to produce smaller and more focused Docker images while keeping the build process inside a single Dockerfile.
4. How Multi-Stage Builds Actually Work
A multi-stage Dockerfile uses multiple FROM instructions to create separate build stages. Each stage is independent and can contain only the tools and dependencies required for its specific purpose.
A typical multi-stage build has two main stages:
Build Stage
Contains the source code, compiler, SDK, and build dependencies required to produce the application.
Runtime Stage
Contains the minimal base image and only the artifacts required to run the application.
Docker allows files to be copied from one stage into another using COPY --from.
The basic flow is:
Build Stage → Build Artifact → Runtime Stage
Naming a Build Stage
A stage can be given a name using the AS keyword:
FROM <base-image> AS builder
The name makes it possible to reference that stage later in the Dockerfile.
Copying Artifacts Between Stages
The COPY --from instruction selects files from a previous stage:
COPY --from=builder <source> <destination>
Only the files explicitly copied into the final stage are included there. The compiler, source code, build dependencies, and other files that remain in the build stage are not carried into the final image.
This is the key mechanism behind multi-stage builds: build everything you need in one environment, then selectively transfer only the output required for production.
5. Before Multi-Stage Builds: A Single-Stage Dockerfile
To understand the benefit of multi-stage builds, let’s first look at a typical single-stage Dockerfile.
In a single-stage build, the same image is used to install build dependencies, compile the application, and run it in production. For a compiled application, this means the final image can contain both the tools used to build the application and the application itself.
Consider a simple Go application:
FROM golang:1.24
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app
EXPOSE 8080
CMD ["./app"]
This Dockerfile works, but the final image is based on the Go image, which contains the Go toolchain required to compile the application.
The important point is that after go build finishes, the compiler and other build-time components are no longer needed to run the resulting binary.
However, because everything happens in the same stage, they remain part of the final image.
The resulting image therefore contains:
Go Toolchain + Build Dependencies + Source Code + Compiled Binary
Only the compiled binary and its runtime requirements are actually needed to run the application.
This is the inefficiency that multi-stage builds are designed to eliminate.
6. After Multi-Stage Builds: Separating Build and Runtime
The single-stage Dockerfile can be improved by separating the build process from the production runtime.
Instead of using the Go image for the entire application lifecycle, we can use it only to build the application. A second stage can then use a smaller runtime image and copy only the compiled binary from the build stage.
FROM golang:1.24 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app
FROM alpine:3.22
WORKDIR /app
COPY --from=builder /app/app .
EXPOSE 8080
CMD ["./app"]
The Dockerfile now has two separate stages.
Build Stage
The first stage uses the Go image because it provides everything required to compile the application.
It contains:
- Go compiler and toolchain
- Build dependencies
- Source code
- Compiled application
However, this stage is used only during the build process.
Runtime Stage
The second stage starts from a minimal Alpine image. It does not contain the Go compiler, source code, or other build-time components.
The COPY --from=builder instruction copies only the compiled application binary from the build stage into the runtime image.
The final image therefore contains:
Minimal Runtime Image + Compiled Application
instead of:
Go Toolchain + Build Dependencies + Source Code + Compiled Application
This is the fundamental advantage of a multi-stage Docker build: the build environment is used to create the application, but it does not need to be shipped with the application.
7. Measuring the Difference
The main goal of using multi-stage builds is to keep unnecessary build-time components out of the final Docker image. The easiest way to verify the improvement is to compare the image sizes before and after applying a multi-stage build.
For the single-stage Dockerfile, the final image is based on the Go image and therefore includes the Go toolchain and other build-time components.
With the multi-stage Dockerfile, the final image is based on a minimal runtime image and contains only the compiled application.
You can check the size of a Docker image using:
docker images
Or:
docker image ls
The comparison should look conceptually like this:
Single-stage → Larger
Multi-stage → Smaller
The exact reduction depends on the application, its dependencies, and the selected runtime image. Multi-stage builds do not remove dependencies that the application actually needs at runtime; they remove components that are required only during the build process.
The important improvement is therefore not simply that the Dockerfile has multiple stages. It is that the final stage contains only the components required to run the application.
8. Choosing the Right Runtime Image
Once the build environment has been separated from the runtime environment, the next step is choosing an appropriate base image for the final stage.
The runtime image should contain only what the application needs to run. Using a large general-purpose base image can unnecessarily increase the final Docker image size even after removing the build dependencies.
Common choices include:
- Slim images: Smaller versions of standard language images with fewer packages.
- Alpine: Lightweight Linux images commonly used when the application is compatible with Alpine’s environment.
- Distroless: Images containing only the application and its runtime dependencies, without a traditional shell or package manager.
- Scratch: An empty base image suitable for applications that can run without additional operating-system components, such as many statically compiled Go applications.
The smallest image is not always the best choice. Runtime compatibility, required libraries, debugging requirements, security considerations, and operational needs should all be considered when selecting the final base image.
The objective is simple: use the smallest practical runtime image that contains everything the application actually needs.
9. Multi-Stage Builds for Real Applications
The basic Go example demonstrates the core idea, but the same pattern is useful for applications where the build process produces artifacts that are different from the source code.
A common example is a frontend application built with Node.js. The Node.js environment may be required to install dependencies and compile the application, but the production container may only need the generated static files.
The build process can therefore be separated into two stages:
Build Stage
Node.js + Dependencies + Source Code → Production Build
Runtime Stage
Minimal Web Server + Production Build → Final Image
For example, a frontend application can be built using a Node.js image and then served using a lightweight Nginx image:
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
The Node.js image is required only while building the application. The final image contains the generated frontend files and Nginx, but does not contain Node.js, npm, the source code, or the development dependencies.
This pattern is especially useful for applications where the build environment is significantly larger than the environment required to serve the final artifact.
10. Common Mistakes with Multi-Stage Builds
Multi-stage builds can significantly reduce Docker image size, but simply adding multiple FROM instructions does not automatically produce an optimized image. The final stage still needs to be designed carefully.
Copying More Than the Application Requires
The final stage should contain only the files required at runtime. Copying the entire build directory or source tree can bring unnecessary files back into the final image.
Use COPY --from to select only the required build artifacts.
Using a Large Runtime Image
Separating the build and runtime stages is only part of the optimization. If the final stage uses a large base image, the resulting image can still be unnecessarily large.
Choose a minimal runtime image that is compatible with the application.
Installing Build Dependencies in the Runtime Stage
Installing compilers, SDKs, development libraries, or other build tools again in the final stage defeats the purpose of separating build and runtime environments.
Build dependencies should remain in the build stage whenever possible.
Ignoring .dockerignore
Even with a multi-stage build, unnecessary files can still be sent as part of the Docker build context.
A properly configured .dockerignore should exclude files such as local dependencies, build output, Git metadata, logs, and other files that are not required during the image build.
Assuming Smaller Always Means Better
Reducing image size is useful, but the smallest possible image is not always the correct production image. Compatibility, security, observability, debugging, and operational requirements should also be considered when choosing the runtime environment.
A good multi-stage build is not simply the smallest Docker image possible. It is a minimal runtime image containing everything required to run the application and nothing that is only needed to build it.
11. Other Ways to Reduce Docker Image Size
Multi-stage builds are one of the most effective ways to separate build-time dependencies from the production image, but they are not the only way to optimize Docker image size.
Use a Minimal Base Image
Choose a base image that provides only what the application needs at runtime. A smaller and appropriate base image can significantly reduce the final image size.
Install Only Required Dependencies
Avoid installing development packages, debugging tools, or other dependencies that are not required by the application in production.
For package-based applications, install production dependencies separately from development dependencies whenever possible.
Use .dockerignore
A .dockerignore file prevents unnecessary files from being included in the Docker build context.
Common examples include:
.gitnode_modules- Local build output
- Logs
- Environment-specific files
- Temporary files
This reduces the amount of unnecessary data sent to the Docker daemon or build engine during the build.
Optimize Dockerfile Layering
Arrange Dockerfile instructions so that layers that change less frequently are built before layers that change frequently. This allows Docker to reuse cached layers and avoid repeating unnecessary work during subsequent builds.
These techniques complement multi-stage builds. The goal is not to rely on a single optimization, but to ensure that the final production image contains only the components required to run the application.
12. Best Practices for Multi-Stage Builds
A well-designed multi-stage Dockerfile should keep the build process reproducible while ensuring that the final image contains only what is required at runtime.
Separate Build and Runtime Responsibilities
Keep compilers, SDKs, development dependencies, and other build tools in the build stage. The final stage should focus exclusively on running the application.
Copy Only Required Artifacts
Use COPY --from to transfer only the files required by the runtime environment. Avoid copying source code, build caches, temporary files, or development dependencies into the final stage.
Use a Minimal Runtime Base
Choose a runtime image appropriate for the application rather than reusing the larger build image. Consider slim, Alpine, distroless, or scratch images where they are compatible with the application.
Optimize Dependency Installation
Install only the dependencies required for the production application. Where the ecosystem supports it, keep development dependencies out of the final runtime environment.
Take Advantage of Build Cache
Place instructions that change less frequently earlier in the Dockerfile and frequently changing instructions later. For example, copying dependency manifests before the application source code can allow Docker to reuse the dependency installation layer when only application code changes.
Keep the Final Stage Simple
The final stage should clearly show what the production container needs: the runtime base, required artifacts, configuration, and startup command.
The objective of these practices is straightforward:
Build with everything you need, but ship only what you need.
13. Conclusion
Docker multi-stage builds provide a practical way to separate the environment required to build an application from the environment required to run it.
Instead of shipping compilers, SDKs, development dependencies, source code, and other build-time components in the production image, the build stage can produce the required application artifacts and the runtime stage can contain only those artifacts and their runtime dependencies.
The result is a smaller and more focused Docker image that can reduce image transfer time, storage requirements, and unnecessary components in production.
The core principle is simple:
Build with everything you need. Ship only what you need.
For production Docker images, multi-stage builds should be considered alongside appropriate runtime base images, dependency optimization, .dockerignore, and effective Docker layer caching.
Top comments (0)