| |

Docker 21 🐳 Multi-Stage Builds: Reducing Image Sizes from Gigabytes to Megabytes

A Docker image built without a strategy becomes an archaeological record of everything that went into producing it. The compiler, the development dependencies, the source code, the package manager caches—all of it remains in the final artifact, even though the running container needs none of it. A straightforward Node.js or Java build easily reaches 1.2 GB, while the application it runs might be a few hundred kilobytes of JavaScript or a compiled binary . Multi-stage builds are the fix. They let you compile in one environment and ship a different one, discarding everything the runtime does not need.

This chapter covers multi-stage builds in full. You will learn the syntax, how stages reference each other, how to copy artifacts selectively, and how to use named stages and build targets. You will also see the mechanics of why the final image shrinks, how layer caching interacts with stages, and the patterns that make multi-stage Dockerfiles maintainable. The goal is not just smaller images but a cleaner separation between build-time and runtime concerns.

Key point: A multi-stage Dockerfile contains multiple FROM instructions. Each one starts a new stage. Only the final stage becomes the published image. Intermediate stages exist during the build and are discarded afterward. The COPY --from=<stage> instruction moves artifacts between stages.


Why multi-stage builds exist

The image bloat problem. A single-stage Dockerfile installs build tools, downloads dependencies, compiles source, and produces a final image that contains all of it. A Java application requires a JDK to compile, but only a JRE to run. A Go application requires the entire Go toolchain to build, but the compiled binary needs nothing but a Linux kernel. Bundling the compiler into the runtime image is waste—it consumes disk, slows image pulls, and expands the attack surface .

The layer accumulation problem. Docker images are built in layers. Each RUN, COPY, and ADD instruction creates a layer. If you install packages in one layer and remove them in another, both layers remain in the image. The removal only masks the files; it does not reclaim the space. This is why RUN apt-get update && apt-get install && rm -rf /var/lib/apt/lists/* is a single instruction—the cleanup must happen in the same layer as the installation, or the cache files persist . Multi-stage builds sidestep this entirely: the build layers are in a stage that never becomes the final image.

The security problem. Every tool in an image is a potential vulnerability. A compiler, a package manager, a shell, and a set of development libraries all have their own CVE surface. A runtime image that contains only the application and its runtime dependencies has a fraction of that surface. The C++ guide on Docker’s documentation notes that a scratch-based final image “contains only the static binary and none of the build dependencies or usual OS tools,” and the absence of a shell keeps the image small and reduces its attack surface .

The reproducibility problem. A build that happens on a developer’s laptop and a build that happens in CI can differ if the environments differ. Multi-stage builds move the build into a container, making the toolchain explicit and pinned. The build stage uses a known image with a known compiler version; the runtime stage uses a known runtime image. Both are declared in the Dockerfile, not in a README .

The readability problem. Without multi-stage builds, complex builds require separate Dockerfiles for building and running, or shell scripts that coordinate docker run, docker cp, and docker build. Multi-stage builds put everything in one file, with named stages that document the build process .


a. Basic syntax and stage references

A multi-stage Dockerfile has multiple FROM instructions. The first stage is typically the build environment; the last stage is the runtime environment.

# syntax=docker/dockerfile:1
FROM golang:1.25 AS build
WORKDIR /src
COPY main.go .
RUN go build -o /bin/hello ./main.go

FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]

The first stage is named build with AS build. The second stage uses scratch, an empty image. The COPY --from=build instruction copies the compiled binary from the build stage into the final stage. The Go toolchain, the source code, and the intermediate build cache are left behind .

Without AS build, stages are referred to by their numeric index, starting at 0. COPY --from=0 would work in the example above, but numeric references break when stages are inserted or reordered. Named stages are the recommended approach .

Stages can also inherit from other stages. FROM build AS publish creates a new stage that starts from the build stage rather than from a registry image. This is used in the .NET example from Microsoft’s training material, where the publish stage runs dotnet publish on top of the build stage, and the final stage copies the published output .

A stage can also use an external image as its source for copying. COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf copies a file from a published image without creating a stage for it. This is useful when you need a configuration file or binary from another project .


b. Build stages, runtime stages, and the final image

The distinction between build stages and runtime stages is the core of the pattern. Build stages contain the toolchain; runtime stages contain the application.

A typical Node.js build stage:

FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

The build stage installs all dependencies (including dev dependencies), runs the build, and then prunes the dev dependencies from node_modules. The npm prune --omit=dev command removes packages that are only needed for development, so the runtime stage receives a lean dependency tree .

The runtime stage:

FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

The runtime stage uses node:22-slim instead of node:22. The slim variant excludes the full toolchain, Python, GCC, and other build-time dependencies that the full image includes for native module compilation. The COPY --from=build instructions bring only the pruned node_modules and the compiled dist directory. The source code, the TypeScript compiler, and the full dependency tree never enter the final image .

The size difference is dramatic. A single-stage Node.js image built this way reaches 1.21 GB. The multi-stage version is 241 MB—a reduction of roughly 80% . The final image contains the runtime, the compiled application, and the production dependencies. Nothing else.

For compiled languages, the reduction is even more extreme. A Go application can ship on scratch, an empty image, because the compiled binary is statically linked and needs no OS libraries. A C++ application compiled with -static can do the same . The final image contains a single binary and nothing else.


c. Build targets and caching

Multi-stage builds support a --target flag that stops the build at a named stage. This is useful for debugging, testing, and development workflows.

docker build --target build -t myapp:build .

This builds only up to the build stage, producing an image that contains the full toolchain and the compiled artifacts. You can run a shell in this image to inspect the build environment or debug a compilation failure .

A test stage can be inserted between build and runtime. If the tests fail, the build fails, and the broken code never reaches the runtime stage:

FROM build AS test
RUN npm test

The test stage depends on build and runs the test suite. The final stage depends on build directly, not on test, so the tests run only when the build reaches that stage. In a CI pipeline, you would target the test stage explicitly, or use a build argument to conditionally include it .

BuildKit, the modern Docker builder, only processes stages that the target depends on. If a Dockerfile has three stages and you target the third, BuildKit builds only the first and third—the second stage, if not a dependency, is skipped entirely. The legacy builder processed all stages up to the target regardless of dependency. This makes BuildKit faster for multi-stage builds with independent stages .

Layer caching works per stage. If the build stage’s inputs have not changed, the build stage is cached, and only the runtime stage’s COPY and configuration run. This is why the order of instructions matters: copying package.json and running npm ci before copying the rest of the source means the dependency installation layer is cached as long as package.json does not change .


Complete Example Session

# ============================================
# PART 1: SINGLE-STAGE BASELINE (1.2 GB)
# ============================================
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
# ============================================
# PART 2: MULTI-STAGE WITH NAMED STAGES
# ============================================
# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
# ============================================
# PART 3: BUILDING AND MEASURING
# ============================================
# docker build -t myapp:single .
# docker images myapp:single --format '{{.Size}}'
# → 1.21GB

# docker build -t myapp:multi .
# docker images myapp:multi --format '{{.Size}}'
# → 241MB
# ============================================
# PART 4: GO WITH SCRATCH FINAL STAGE
# ============================================
FROM golang:1.25 AS build
WORKDIR /src
COPY main.go .
RUN go build -o /bin/hello ./main.go

FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]
# ============================================
# PART 5: NAMED STAGE REFERENCE
# ============================================
FROM alpine:latest AS builder
RUN apk --no-cache add build-base

FROM builder AS build1
COPY source1.cpp source.cpp
RUN g++ -o /binary source.cpp

FROM builder AS build2
COPY source2.cpp source.cpp
RUN g++ -o /binary source.cpp
# ============================================
# PART 6: .NET PUBLISH STAGE
# ============================================
FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
WORKDIR /src
COPY ["WebApplication1.csproj", ""]
RUN dotnet restore "./WebApplication1.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "WebApplication1.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "WebApplication1.csproj" -c Release -o /app/publish

FROM base AS final
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WebApplication1.dll"]
# ============================================
# PART 7: BUILDING A SPECIFIC TARGET
# ============================================
# docker build --target build -t myapp:build .
# docker run -it myapp:build sh
# → Shell in the build stage for debugging
# ============================================
# PART 8: TEST STAGE
# ============================================
FROM build AS test
WORKDIR /app
RUN npm test
# ============================================
# PART 9: COPYING FROM EXTERNAL IMAGE
# ============================================
FROM php:8.4-cli AS hyde
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json .
RUN composer install
# ============================================
# PART 10: C++ WITH SCRATCH
# ============================================
FROM ubuntu:latest AS build
RUN apt-get update && apt-get install -y build-essential
WORKDIR /app
COPY hello.cpp .
RUN g++ -o hello hello.cpp -static

FROM scratch
COPY --from=build /app/hello /hello
CMD ["/hello"]

The ten parts covered a single-stage baseline, a multi-stage Node.js build, the size comparison, a Go scratch build, named stage inheritance, a .NET publish pipeline, build targets, test stages, external image copying, and a C++ scratch build.


Quick Reference

Multi-Stage Syntax

InstructionPurpose
FROM image AS nameStart a named stage
FROM stage AS nameStart a stage from another stage
COPY --from=name /src /destCopy from a stage
COPY --from=0 /src /destCopy by numeric index
COPY --from=image /src /destCopy from external image
docker build --target nameBuild up to a stage

Stage Types

Stage TypeBase ImageContains
BuildSDK/toolchainCompiler, source, dependencies
PublishBuild stageCompiled artifacts
TestBuild stageTest suite, test dependencies
RuntimeSlim/scratchApplication, runtime only

Why Images Shrink

RemovedReason
Compiler toolchainNot needed at runtime
Dev dependenciesNot loaded by production code
Source codeReplaced by compiled artifacts
Package cachesInstalled then discarded
Shell and OS toolsNot needed for execution

Build Target Use Cases

ScenarioTarget
Debug build failure--target build
Run tests only--target test
Inspect build environment--target build + sh
CI pipelineDefault (final stage)

Best Practices

✅ Do This:

# Use named stages
FROM node:22 AS build                                                # ✅

# Use slim or scratch for runtime
FROM node:22-slim AS runtime                                         # ✅

# Copy only artifacts
COPY --from=build /app/dist ./dist                                   # ✅

# Prune dev dependencies in build stage
RUN npm ci && npm run build && npm prune --omit=dev                  # ✅

# Clean apt cache in the same layer
RUN apt-get update && apt-get install -y --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*                                   # ✅

# Use .dockerignore to exclude unnecessary files
# (node_modules, .git, logs)                                        # ✅

❌ Don’t Do This:

# Don't copy the entire build stage
COPY --from=build /app /app                                          # ❌

# Don't use full image for runtime
FROM node:22 AS runtime                                              # ❌

# Don't install dev dependencies in runtime stage
RUN npm install                                                      # ❌

# Don't separate install and cleanup
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*                                      # ❌ (cache persists)

Common Pitfalls

PitfallWhy It HappensFix
Numeric stage reference breaksStage inserted or reorderedUse named stages
Final image still largeCopied more than artifactsCopy specific directories only
Tests not runningTarget does not depend on test stageInclude test in final dependency chain
Cache not usedInstruction order wrongCopy package.json before source
Build tools in final imageCopied entire build stageUse COPY --from selectively
Cache files persistCleanup in separate layerCombine install and cleanup in one RUN

Real-World Examples

1. Node.js Multi-Stage

FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci && npm run build && npm prune --omit=dev

FROM node:22-slim
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

2. Go Scratch

FROM golang:1.25 AS build
RUN go build -o /hello .
FROM scratch
COPY --from=build /hello /hello
CMD ["/hello"]

3. .NET Publish

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
RUN dotnet publish -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:8.0
COPY --from=build /app .
ENTRYPOINT ["dotnet", "App.dll"]

4. C++ Static

FROM ubuntu AS build
RUN apt-get install -y build-essential
RUN g++ -o /hello hello.cpp -static
FROM scratch
COPY --from=build /hello /hello

5. Java Spring Boot

FROM maven AS build
RUN mvn package -DskipTests
FROM eclipse-temurin:21-jre
COPY --from=build /target/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

6. Static Site with Nginx

FROM node:22 AS build
RUN npm ci && npm run build
FROM nginx:stable-alpine
COPY --from=build /app/dist /usr/share/nginx/html

7. Python with Virtualenv

FROM python:3.12 AS build
RUN python -m venv /venv
RUN /venv/bin/pip install -r requirements.txt
FROM python:3.12-slim
COPY --from=build /venv /venv

8. PHP with Composer

FROM composer:2 AS vendor
COPY composer.json composer.lock ./
RUN composer install --no-dev

FROM php:8.4-cli
COPY --from=vendor /app/vendor ./vendor
COPY . .

9. Build Target for Debug

FROM node:22 AS build
RUN npm run build
FROM build AS debug
CMD ["sh"]

10. Test Stage

FROM build AS test
RUN npm test
FROM runtime AS final

Visual

Single-Stage vs Multi-Stage

┌─────────────────────────────────────────────────────────────┐
│  SINGLE-STAGE IMAGE (1.2 GB)                                │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  node:22 full image (1.1 GB)                        │    │
│  │  ├── Python, GCC, Git (build tools)                 │    │
│  │  ├── node_modules (all deps)                        │    │
│  │  ├── src/ (TypeScript source)                       │    │
│  │  └── dist/ (compiled output)                        │    │
│  │                                                     │    │
│  │  The container runs dist/server.js.                 │    │
│  │  Everything else is dead weight.                    │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  MULTI-STAGE IMAGE (241 MB)                                 │
│                                                             │
│  Build Stage (discarded):                                   │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  node:22 full image                                 │    │
│  │  ├── npm ci (install all deps)                      │    │
│  │  ├── npm run build (compile)                        │    │
│  │  └── npm prune --omit=dev (remove dev deps)         │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  Runtime Stage (published):                                 │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  node:22-slim (240 MB)                              │    │
│  │  ├── node_modules (production only)                 │    │
│  │  └── dist/ (compiled output)                        │    │
│  │                                                     │    │
│  │  Toolchain, source, dev deps never enter.           │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Stage Flow

┌─────────────────────────────────────────────────────────────┐
│  STAGE DEPENDENCY FLOW                                      │
│                                                             │
│  ┌──────────────┐                                           │
│  │  build       │  (toolchain, source, compile)             │
│  └──────┬───────┘                                           │
│         │                                                   │
│         ├──▶ ┌──────────────┐                               │
│         │    │  test        │  (run tests)                  │
│         │    └──────────────┘                               │
│         │                                                   │
│         ├──▶ ┌──────────────┐                               │
│         │    │  publish     │  (package artifacts)          │
│         │    └──────┬───────┘                               │
│         │           │                                       │
│         │           ▼                                       │
│         │    ┌──────────────┐                               │
│         └──▶ │  final       │  (runtime, published image)   │
│              └──────────────┘                               │
│                                                             │
│  Only stages the target depends on are built.               │
│  Named stages survive reordering.                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Layer Caching Across Stages

┌─────────────────────────────────────────────────────────────┐
│  CACHING BEHAVIOR                                           │
│                                                             │
│  Build stage:                                               │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  COPY package.json                    ← cached      │    │
│  │  RUN npm ci                           ← cached      │    │
│  │  COPY . .                             ← rebuilds    │    │
│  │  RUN npm run build                    ← rebuilds    │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  If package.json unchanged:                                 │
│  - npm ci layer reused from cache                           │
│  - only source copy and build run                           │
│                                                             │
│  Runtime stage:                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  COPY --from=build /app/dist          ← rebuilds    │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  Order matters: dependencies first, source second.          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

What Gets Discarded

┌─────────────────────────────────────────────────────────────┐
│  DISCARDED AFTER BUILD                                      │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  Compiler toolchain (GCC, JDK, Go, Rust)            │    │
│  │  Package manager caches (npm, pip, apt, maven)      │    │
│  │  Development dependencies (jest, eslint, etc.)      │    │
│  │  Source code (TypeScript, Java, Go source)          │    │
│  │  Intermediate build artifacts (.o, .class, etc.)    │    │
│  │  Shell and OS utilities (bash, coreutils)           │    │
│  │  Python, Git, curl (build-time deps)                │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  PUBLISHED IN FINAL IMAGE                           │    │
│  │                                                     │    │
│  │  Compiled application (dist/, binary)               │    │
│  │  Production dependencies (pruned node_modules)      │    │
│  │  Runtime (node, JRE, libc)                          │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Multi-stage DockerfileMultiple FROM instructions
Named stageFROM image AS name
Stage referenceCOPY --from=name /src /dest
Numeric referenceCOPY --from=0 /src /dest
Final imageLast stage only
Discarded stagesAll intermediate stages
Build targetdocker build --target name
Typical reduction80% or more
Node.js example1.21 GB → 241 MB
Go exampleFull toolchain → scratch binary
Layer cachingPer stage; order matters
Security benefitReduced attack surface

Key takeaways:

  • Multi-stage builds separate build-time from runtime. The build stage contains the toolchain, source, and dependencies. The runtime stage contains only the application and its runtime dependencies. Everything else is discarded.
  • Only the final stage becomes the image. Intermediate stages exist during the build and are not published. This is why the final image can be orders of magnitude smaller than the sum of its build steps.
  • COPY --from moves artifacts between stages. Use named stages for readability; numeric references break when stages are inserted or reordered.
  • The reduction is typically 80% or more. A Node.js image built single-stage reaches 1.21 GB; the multi-stage version is 241 MB. A Go application can ship on scratch, an empty image, because the compiled binary is statically linked.
  • Build targets enable debugging and testing. docker build --target build stops at the build stage, letting you inspect the environment. A test stage runs tests between build and runtime; if tests fail, the build fails.
  • Layer caching works per stage. Copying package.json before the rest of the source means the dependency installation layer is cached until package.json changes. Order instructions to maximize cache reuse.
  • Cleanup must happen in the same layer. RUN apt-get install && rm -rf /var/lib/apt/lists/* removes the cache in the same layer. Separating install and cleanup leaves the cache in the earlier layer.
  • The security surface shrinks with the image. A final image without a compiler, package manager, or shell has far fewer potential vulnerabilities than one that bundles the entire build toolchain.

Remember: A Docker image is not a snapshot of the build process. It is the artifact the runtime needs. Multi-stage builds enforce this distinction by making the build environment explicit and the runtime environment minimal. The compiler, the package manager, the source code, and the development dependencies all belong in a stage that is discarded. The final image contains what runs, nothing more. The size reduction is the headline, but the real benefit is the separation of concerns: build-time complexity stays in the build stage, and the runtime stage stays clean, secure, and predictable.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!