Multi-stage Docker Builds
Learn how multi-stage Docker builds work and how to use separate build and runtime stages to create smaller, cleaner and more efficient Docker images.
Multi-stage builds are one of the most useful Docker techniques for creating smaller and cleaner container images. Instead of using one environment for every step of the application lifecycle, a Dockerfile can define multiple stages. One stage can contain compilers, development dependencies and build tools, while another contains only the files and runtime dependencies required to run the finished application.
This approach is especially useful for compiled applications and modern JavaScript projects. A production application often needs many packages and tools during the build process that have no purpose after the application has been compiled or bundled. Multi-stage builds allow those components to remain in the build stage instead of becoming part of the final runtime image.
What Is a Multi-stage Docker Build?
A multi-stage Docker build is a Dockerfile that contains multiple FROM instructions. Each FROM starts a new build stage. Files or build artifacts can then be selectively copied from one stage to another using COPY --from.
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]In this example, the builder stage installs all dependencies and creates the production build. The runner stage starts from a separate image and copies only the generated application files from the builder.
The final image therefore does not automatically contain everything that existed in the builder stage. Only the files explicitly copied into the final stage become part of that stage.
Why Use Multi-stage Builds?
The main reason to use multi-stage builds is to separate build-time requirements from runtime requirements. A build environment can be relatively large because it needs compilers, package managers, development libraries and tooling. A runtime environment can often be much smaller.
- Reduce the final Docker image size.
- Keep development dependencies out of production images.
- Separate build tools from runtime tools.
- Reduce the number of packages that need to be maintained.
- Reduce the runtime image attack surface.
- Make production images easier to understand.
- Create cleaner CI/CD pipelines.
- Reuse common build stages for different image targets.
Single-stage vs Multi-stage Builds
In a single-stage Dockerfile, the same image is used to install dependencies, build the application and run it. This is simple, but everything installed during the build can remain in the resulting image.
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]The same Dockerfile can be converted into a multi-stage build by introducing a dedicated build stage and a separate runtime stage.
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]The second version gives the runtime image a separate environment. The build image can contain everything necessary to compile the application without requiring those tools to exist in production.
How Multi-stage Builds Work
Every FROM instruction starts a new stage. A stage can optionally receive a name using the AS keyword. Naming stages makes COPY --from instructions easier to understand and prevents them from depending on numeric stage positions.
FROM node:22 AS builder
# Build commands
FROM node:22-slim AS runner
COPY --from=builder /app/dist /app/distThe builder stage and runner stage are separate filesystem environments. The COPY --from=builder instruction explicitly selects files from the builder stage and places them into the runner stage.
Docker does not simply merge the complete filesystem of the builder into the final image. This selective copying is what makes multi-stage builds useful for reducing the final image contents.
Naming Build Stages
Although Docker supports copying from stages by numeric index, named stages are generally easier to maintain.
FROM node:22 AS builder
FROM node:22-slim AS production
COPY --from=builder /app/dist /app/distIf another stage is inserted into the Dockerfile later, numeric references can become confusing. A descriptive name such as builder, production, development or test makes the Dockerfile easier to understand.
COPY --from Explained
COPY --from is the key instruction used to transfer files between build stages.
COPY --from=builder /app/dist ./distThe first path is the source path inside the builder stage. The second path is the destination inside the current stage.
COPY --from=builder /app/package.json ./
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./publicOnly the artifacts that are actually required should normally be copied into the runtime stage. Copying the entire application directory defeats much of the benefit of a multi-stage build.
Node.js Multi-stage Build
Node.js applications are a common use case because development and production dependencies can differ significantly. A build stage can install the complete dependency tree, compile the application and then pass only the required runtime files to the final stage.
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]Here, npm ci installs all dependencies in the builder because development dependencies may be required to compile the application. The runtime stage performs a separate installation using only production dependencies.
Multi-stage Builds for TypeScript
TypeScript applications are another natural fit for multi-stage builds. TypeScript source files are compiled into JavaScript during the build process, so the production image may not need the TypeScript compiler or source files.
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]The final stage contains the compiled JavaScript and production dependencies rather than the complete TypeScript development environment.
Multi-stage Builds for React Applications
Frontend applications built with React, Vite or similar tools can also benefit from multi-stage builds. The first stage installs dependencies and generates static files. A second stage can use a lightweight web server image to serve those files.
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/htmlThe Node.js environment is needed only while building the frontend. The final image can contain the generated static assets and the web server required to serve them.
Multi-stage Builds for Next.js
Next.js applications can also use multi-stage builds, although the exact runtime files depend on the application's configuration. When using the standalone output mode, the generated standalone directory can be copied into a smaller runtime image.
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]This pattern relies on Next.js standalone output being enabled and is therefore configuration-dependent. A Next.js application without standalone output may require a different set of runtime files.
Separating Development and Production Dependencies
One of the most useful properties of multi-stage builds is that development dependencies do not have to become part of the final runtime environment.
Linters, test runners, TypeScript compilers, bundlers and other build tools can remain in the builder stage. The runtime stage can install only the dependencies required to execute the finished application.
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]Multi-stage Builds and Security
A smaller runtime image can also have security benefits. If a compiler, package manager plugin or debugging utility is not needed to run the application, there is usually little reason to include it in the production image.
Reducing the number of installed components reduces the number of packages that need to be monitored and updated. It can also reduce the number of potentially exploitable components available inside a running container.
Multi-stage builds are not a complete security solution. Images should still use appropriate base images, avoid embedded secrets, run applications with appropriate privileges and undergo vulnerability scanning.
Keeping Secrets Out of Build Stages
Multi-stage builds do not automatically make secrets safe. A secret used during a build can still be exposed if it is written into files, environment variables or image layers incorrectly.
# Avoid hardcoding credentials
ENV API_KEY="secret-value"
# Avoid writing secrets into files
RUN echo "$API_KEY" > /app/config.txtIf a build genuinely requires access to a private package registry or another secret, use Docker's supported secret mechanisms rather than embedding the credential directly into the Dockerfile.
Multiple Build Stages
A Dockerfile does not have to contain only a builder and a runtime stage. Larger projects can define separate stages for development, testing, building and production.
FROM node:22 AS base
WORKDIR /app
COPY package.json package-lock.json ./
FROM base AS dependencies
RUN npm ci
FROM dependencies AS test
COPY . .
RUN npm test
FROM dependencies AS builder
COPY . .
RUN npm run build
FROM node:22-slim AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]This structure can make a complex build pipeline easier to organize. Test and production targets can share common setup without requiring every stage to contain the same commands.
Targeting a Specific Build Stage
A multi-stage Dockerfile can define different targets for different purposes. For example, a development image may contain debugging tools while the production target remains minimal.
FROM node:22 AS development
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]A build command can select a particular target when a project needs an intermediate stage rather than the final production image.
Multi-stage Builds and Docker Cache
Multi-stage builds work together with Docker's layer caching. Each stage has its own sequence of instructions and cached layers. Structuring dependency installation before copying frequently changing source files can allow Docker to reuse expensive installation steps.
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run buildIf only source files change, Docker may be able to reuse the dependency installation layer. If package.json or the lockfile changes, the dependency layer will normally need to be rebuilt.
Use .dockerignore with Multi-stage Builds
Multi-stage builds do not replace .dockerignore. The build context is still sent to Docker before the build begins, so unnecessary files should be excluded.
node_modules
.git
.env
.env.*
.next
dist
coverage
npm-debug.logFor example, if node_modules is already installed inside the builder stage, there is usually no reason to send the host's node_modules directory as part of the build context.
Copy Only Required Build Artifacts
A common mistake is to use a multi-stage Dockerfile but copy the entire builder directory into the runtime stage.
# Too broad
COPY --from=builder /app /appThis can bring source code, configuration files, development artifacts and other unnecessary files into the final image.
# More selective
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./publicThe correct files depend on the application. The goal is to identify the actual runtime artifacts and copy those instead of treating the entire builder filesystem as a runtime dependency.
Using a Different Base Image for Runtime
The builder and runtime stages do not have to use exactly the same base image. A builder may require a full development environment while the runtime can use a smaller image containing only the necessary runtime.
FROM node:22 AS builder
# Build application
FROM node:22-slim AS runner
# Run applicationFor compiled applications, the difference can be even larger. A builder may use a complete development distribution with compilers while the final stage uses a minimal runtime image.
Compiled Languages and Multi-stage Builds
Multi-stage builds are particularly powerful for languages such as Go, Rust and C or C++. The build stage can contain compilers and development libraries, while the final image can contain only the compiled executable and the runtime components it needs.
FROM golang:1.25 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/server ./cmd/server
FROM debian:stable-slim AS runner
COPY --from=builder /app/server /usr/local/bin/server
CMD ["/usr/local/bin/server"]The compiler and Go development environment remain in the builder stage. The production stage receives the compiled binary.
Build Arguments and Multi-stage Builds
ARG values can be used to customize builds, including selecting versions or configuring optional build behavior. ARG is available during image construction and should not be treated as a secure secret-storage mechanism.
ARG NODE_VERSION=22
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run buildBuild arguments are useful for configurable build parameters, but sensitive values should be handled through appropriate secret mechanisms instead of ARG.
Common Multi-stage Build Mistakes
Multi-stage builds are straightforward once the separation between stages is understood, but several mistakes can reduce their benefits or cause runtime failures.
| Mistake | Problem | Better Approach |
|---|---|---|
| Copying the entire builder filesystem | Unnecessary files enter the runtime image | Copy only required runtime artifacts |
| Installing everything again in production | Can increase build time and image size | Install only required runtime dependencies |
| Using numeric stage references everywhere | Dockerfiles become harder to maintain | Use descriptive stage names |
| Assuming the runtime needs source code | Production image becomes larger | Copy compiled or bundled artifacts when possible |
| Ignoring native dependencies | Application may fail at runtime | Ensure required system libraries exist in the runtime image |
| Putting secrets in build stages | Credentials may become exposed | Use supported secret mechanisms |
| Skipping .dockerignore | Build context becomes unnecessarily large | Exclude irrelevant files |
| Using an incompatible runtime image | Application may fail after deployment | Test the final image in an environment similar to production |
How to Debug a Multi-stage Build
When a multi-stage image works during development but fails in production, the first thing to check is what the final stage actually contains. A builder stage may have files or libraries that were never copied into the runtime stage.
- Check which stage is being built.
- Inspect the final image filesystem.
- Verify every required runtime file is copied.
- Check production dependencies.
- Check native system libraries.
- Verify environment variables.
- Verify the container command.
- Run the final image locally before deployment.
It is important to test the final runtime stage itself rather than assuming that a successful build means the resulting container will run correctly.
Multi-stage Build Best Practices
- Give important stages descriptive names.
- Keep build dependencies in builder stages.
- Copy only required runtime artifacts.
- Use .dockerignore to reduce the build context.
- Order dependency installation for effective caching.
- Use production-only dependencies in the runtime stage when appropriate.
- Choose a runtime base image appropriate for the application.
- Avoid embedding secrets in any build stage.
- Run the production container with appropriate privileges.
- Regularly update and scan both builder and runtime base images.
- Test the final production image rather than only the build stage.
- Keep development and production targets separate when their requirements differ significantly.
A Practical Multi-stage Dockerfile Template
The following template demonstrates a general structure that can be adapted to many Node.js applications.
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]This template should be treated as a starting point rather than a universal Dockerfile. The runtime files, dependency strategy and startup command depend on the framework and application architecture.
When Should You Use Multi-stage Builds?
Multi-stage builds are particularly useful when the application has a meaningful build process or when the build environment is substantially larger than the runtime environment.
- TypeScript applications that compile to JavaScript.
- React or Vue applications that produce static assets.
- Next.js applications using a production build.
- Go applications compiled into binaries.
- Rust applications compiled into binaries.
- C and C++ applications requiring compilers.
- Applications with large development dependency trees.
- Projects where minimizing production image contents is important.
For a very simple application with no compilation step and minimal dependencies, a single-stage Dockerfile may be sufficient. Multi-stage builds are most valuable when there is a clear distinction between what is required to build an application and what is required to run it.
Multi-stage Builds vs Multiple Dockerfiles
A project can use separate Dockerfiles for development and production, but multi-stage builds can often keep related configurations in one place. Shared base stages can reduce duplication while separate targets provide different environments.
Multiple Dockerfiles can still make sense when development and production workflows are fundamentally different. The choice depends on how much configuration is shared and how easy the resulting setup is to maintain.
Frequently Asked Questions
What is a multi-stage Docker build?
A multi-stage Docker build is a Dockerfile containing multiple FROM instructions. Each FROM creates a separate build stage, allowing build dependencies and runtime dependencies to be separated. Files can be selectively copied from one stage to another with COPY --from.
Why are multi-stage Docker builds useful?
They allow compilers, development dependencies and other build tools to remain in a builder stage instead of being included in the final runtime image. This can reduce image size, simplify the runtime environment and reduce unnecessary components.
What does COPY --from do?
COPY --from copies files or directories from another build stage, image or external source into the current stage. In multi-stage builds it is commonly used to copy compiled or bundled application artifacts from a builder stage into a production stage.
Does every Dockerfile need multiple stages?
No. Multi-stage builds are most useful when the build environment has dependencies or tools that are not required at runtime. A simple application may not benefit enough to justify the additional structure.
Can builder and runtime stages use different images?
Yes. A builder can use a full development image while the runtime stage uses a smaller image containing only the components needed to execute the application. The runtime image must still provide all required libraries and runtime capabilities.
Are multi-stage builds more secure?
They can reduce the number of unnecessary components in the production image, which can reduce its attack surface and maintenance burden. However, multi-stage builds do not replace vulnerability scanning, secret management, appropriate permissions or other security practices.
Can I use multi-stage builds with Next.js?
Yes. Next.js applications can use multi-stage builds to separate the build environment from the production runtime. The exact files copied into the final stage depend on the Next.js configuration, including whether standalone output is enabled.
Helpful Docker Tools
Multi-stage Docker projects often benefit from tools that simplify related configuration tasks. Dockerfile generators can help create an initial Dockerfile structure, .dockerignore generators can identify files that should stay outside the build context, Docker Compose generators can help define multi-container environments, Compose formatters can improve readability, and environment-variable generators can help organize application configuration.
Conclusion
Multi-stage Docker builds provide a practical way to separate the environment used to build an application from the environment used to run it. The builder stage can contain compilers, development dependencies and other tooling, while the final stage can contain only the runtime and the artifacts required by the application.
The most important principle is selective transfer between stages. Install and build everything you need in the builder, then copy only the files required by the production runtime. Combined with effective Docker caching, a good .dockerignore file, appropriate base images and production-only dependencies, multi-stage builds can produce smaller, cleaner and easier-to-maintain container images.