What Are Multi-Stage Docker Builds?

Multi-stage Docker builds are a feature introduced in Docker 17.05 that allows you to use multiple FROM statements within a single Dockerfile. Each FROM instruction begins a new stage, which can have its own base image, dependencies, and commands. Artifacts can be selectively copied from one stage to another, while the final image only retains what is strictly necessary for running the application. This approach eliminates the need to manually chain Dockerfiles or rely on complex build scripts, and it dramatically reduces image size by excluding compilers, development libraries, and intermediate files.

The core idea is to separate the build environment from the runtime environment. In a typical single-stage build, a developer installs all build tools (e.g., compilers, package managers, test frameworks) in the same image that will be used for production. That bloats the image and increases the attack surface. Multi-stage builds solve this by using a thick, feature-rich image for compilation and then copying only the resulting artifacts into a minimal runtime image such as alpine or distroless.

Benefits of Multi-Stage Builds

The advantages of adopting multi-stage builds go beyond simple size reduction. They have a profound impact on security, maintainability, and deployment speed.

1. Reduced Image Size

By discarding build-time dependencies, multi-stage builds often shrink images by 50% to 90%. For example, a Node.js application built using the full node:14 image (over 300MB) can be slimmed down to under 20MB by copying only the built dist folder into an nginx:alpine base. This storage savings directly translates to faster pull times, less network bandwidth, and lower registry costs.

2. Improved Security

Every installed package or tool in a container image is a potential vulnerability. Multi-stage builds allow you to exclude compilers, debuggers, and development libraries from the final image, significantly reducing the attack surface. You can even use images like scratch or gcr.io/distroless for the runtime stage, which contain only the bare minimum to execute the application binary.

3. Streamlined Build Process

All build steps are defined in a single Dockerfile, making the process self-contained and easy to version. CI/CD pipelines benefit from a single entry point: the Dockerfile. There is no need to maintain separate build scripts or manual cleanup steps.

4. Enhanced Reproducibility and Consistency

Because the entire build is captured in one Dockerfile, any developer or system can reproduce the exact same layers. The use of specific version tags for base images further guarantees consistent builds across environments.

Building a Multi-Stage Dockerfile: Step by Step

This walkthrough covers the creation of a production‑ready multi‑stage Dockerfile for a Node.js and React application. The same principles apply to any compiled language.

1. Plan Your Stages

Before writing code, map out the stages you need. A typical multi‑stage build has at least two stages:

  • Builder stage – installs all build tools, installs dependencies, and runs the build command.
  • Runtime stage – uses a minimal base image, copies only the built artifacts from the builder stage, and defines the runtime behavior.

For complex projects you might add intermediate stages for tests, static analysis, or asset compression.

2. Write the Builder Stage

Start with a base image that includes the required toolchain. Use named stages with AS to reference them later. For Node.js:

FROM node:14-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

Key points:

  • Use npm ci for deterministic, faster dependency installation.
  • Keep RUN commands as separate layers when possible to leverage caching.
  • Install build tools (like TypeScript, webpack) in this stage only.

3. Add an Intermediate Test Stage (Optional)

To enforce code quality, add a stage that runs tests. This stage can reuse the builder image or install additional tools. Because it is not the final stage, test failures will not be present in the final image.

FROM builder AS test
RUN npm run test

You can run this stage in your CI pipeline with docker build --target test -t myapp:test . to catch failures early without building the entire final image.

4. Define the Runtime Stage

For a React application, the runtime image can be an Nginx server. For a backend API, it might be a distroless base image or a minimal Alpine with the Node.js runtime. Copy only the essential artifacts using --from=builder.

FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

If you need the Node.js runtime, avoid copying node_modules from the builder; instead reinstall production dependencies in the runtime stage:

FROM node:14-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
RUN npm ci --only=production
EXPOSE 3000
CMD ["node", "dist/server.js"]

5. Build and Test the Image

Build the final image using the standard command:

docker build -t myapp:latest .

To verify the size, run docker images and compare against a single‑stage build. Run the container and confirm the application responds correctly:

docker run -d -p 8080:80 myapp:latest
curl http://localhost:8080

Best Practices for Multi-Stage Builds

  • Use specific base image tags – avoid latest to prevent surprises. Prefer node:14-alpine or golang:1.20-buster.
  • Optimize layer caching – copy package.json and package-lock.json before the rest of the source code so that the npm install layer is only invalidated when dependencies change.
  • Leverage buildKit – enable BuildKit with DOCKER_BUILDKIT=1 for faster builds, inline caching, and better parallelism.
  • Create multiple final stages for different environments – for example, a development stage with debugging tools and a production stage with a hardened base image.
  • Use --target for development builds – in development you can stop at the builder stage to get live reload and source maps, then rebuild with --target runtime for production.
  • Keep secrets out of images – use Docker BuildKit’s --secret flag if you need to pass credentials during build; never include them in the final image.

Common Patterns and Use Cases

Compiled Languages (Go, Rust, C++)

For static binaries, the runtime stage can use scratch (empty base image). Only the binary and maybe a configuration file are copied. Example for Go:

FROM golang:1.20-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp .

FROM scratch
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]

Python Applications

Use a builder stage with python:3.11-slim to install dependencies and compile any C extensions, then copy only the installed packages to a runtime stage:

FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt
COPY . .

FROM python:3.11-slim
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app /app
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]

Frontend with API Server

Build both frontend and backend in one Dockerfile. Use separate builder stages for each, then copy both artifacts into a single runtime image:

FROM node:14-alpine AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build

FROM node:14-alpine AS api-builder
WORKDIR /app
COPY api/package*.json ./
RUN npm ci
COPY api/ .
RUN npm run build

FROM node:14-alpine
WORKDIR /app
COPY --from=frontend-builder /app/build ./public
COPY --from=api-builder /app/dist ./dist
RUN npm ci --only=production
EXPOSE 3000
CMD ["node", "dist/server.js"]

Troubleshooting Multi-Stage Builds

  • Layer caching not working – ensure COPY commands order dependencies before source code. Use .dockerignore to exclude unnecessary files.
  • Artifact not found – verify the paths in the COPY --from statement. The builder stage must produce output in the specified location. Use RUN ls /app/build to debug.
  • Secret leakage – never copy entire directories that might contain .env or credentials.json. Explicitly copy only needed files.
  • Large final images despite multi-stage – check if you are accidentally copying node_modules or the entire source. Use docker history to see layer sizes.

Conclusion

Multi-stage Docker builds are a cornerstone of modern containerization. They enable you to ship lean, secure images while keeping the build process simple and documented in a single Dockerfile. By separating concerns between build and runtime, you can drastically reduce image size, improve security, and streamline CI/CD pipelines. The techniques shown here apply to nearly any stack—whether you are building Node.js, Go, Python, Java, or frontend applications. Start by converting your existing Dockerfiles to multi-stage builds, and you will immediately see improvements in build speed, deployment efficiency, and overall infrastructure costs.

For more details, refer to the official Docker multi-stage build documentation and the Dockerfile best practices guide. Real‑world examples are also available in the Docker Library documentation.