Ctrl + K
Docker17 min read

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.

Published: 2026-10-05

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/dist

The 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/dist

If 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 ./dist

The 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 ./public

Only 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/html

The 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.

⚠️ Do not blindly copy this example into every Next.js project. Check the application's Next.js configuration and determine which files are actually required by the generated runtime.

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.txt

If 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 build

If 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.log

For 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 /app

This 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 ./public

The 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 application

For 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.

⚠️ Do not choose an extremely minimal runtime image without checking compatibility. Native modules and system libraries may require components that are not present in a minimal base 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 build

Build 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.

MistakeProblemBetter Approach
Copying the entire builder filesystemUnnecessary files enter the runtime imageCopy only required runtime artifacts
Installing everything again in productionCan increase build time and image sizeInstall only required runtime dependencies
Using numeric stage references everywhereDockerfiles become harder to maintainUse descriptive stage names
Assuming the runtime needs source codeProduction image becomes largerCopy compiled or bundled artifacts when possible
Ignoring native dependenciesApplication may fail at runtimeEnsure required system libraries exist in the runtime image
Putting secrets in build stagesCredentials may become exposedUse supported secret mechanisms
Skipping .dockerignoreBuild context becomes unnecessarily largeExclude irrelevant files
Using an incompatible runtime imageApplication may fail after deploymentTest 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.

Found an issue?

Found an error, outdated information, or something missing from this article? Let me know through the Contact page.

Your feedback helps improve our articles and keep them accurate and useful.