Multi-Stage Builds
Multi-stage builds let one Dockerfile use several temporary build environments and copy only the finished artifacts into the final image. They matter because real applications often need compilers, package managers, test tools, and source files to build, but production containers usually need only a small runtime and the compiled output.
The result is a smaller, cleaner, and safer image without maintaining separate Dockerfiles for build and production.
Overview: How Multi-Stage Builds Work
A Docker image is a stack of read-only layers. Each Dockerfile instruction such as FROM, COPY, or RUN can create or reuse a layer. A container adds a thin writable layer on top of those image layers when it runs. Multi-stage builds use this same layer model, but divide the Dockerfile into named stages.
Every FROM starts a new stage. A stage has its own base image, filesystem, installed packages, working directory, and layers. Earlier stages can contain heavy build tools: a Go compiler, Node package manager, Java build system, C libraries, or test dependencies. The final stage starts from a fresh runtime image and uses COPY --from=... to copy specific files out of a previous stage.
Only the final stage becomes the image you normally tag and run. The builder stages are used during the build, and their layers may remain in the local build cache, but they are not part of the final image. This is the key benefit: you can compile in a large image and ship from a small image.
This also helps security. If the final image does not contain source code, package manager caches, compilers, Git, or test tools, there is less to scan, patch, and attack. Multi-stage builds do not replace good dependency management, but they are the standard Docker pattern for production images.
Syntax
FROM base-image:tag AS stage-name
WORKDIR /path
RUN command
COPY source destination
FROM runtime-image:tag AS final
WORKDIR /path
COPY --from=stage-name /path/from/builder /path/in/final
CMD ["executable", "arg"]
| Part | Meaning |
|---|---|
FROM base-image:tag AS stage-name |
Starts a build stage and gives it a readable name. Use pinned tags such as node:20-alpine, not latest, because latest can change without warning. |
WORKDIR |
Sets the directory for later RUN, COPY, and CMD instructions in that stage. |
RUN |
Executes commands during build time and stores the resulting filesystem changes in a layer. |
COPY --from=stage-name |
Copies files from another stage into the current stage. This is the main multi-stage build feature. |
AS final |
A normal stage name. Docker builds the last stage by default unless you select another target. |
CMD |
Defines the default process to run when a container starts from the image. |
You can also build only one stage with --target. This is useful for debugging a builder stage or creating a separate development image.
docker build --target builder -t myapp:builder .
Examples
Example 1: Build a Go Binary, Ship Only the Binary
This Dockerfile compiles a Go application in a full Go image, then copies the compiled binary into a tiny Alpine runtime image.
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /out/server ./cmd/server
FROM alpine:3.20 AS final
WORKDIR /app
COPY --from=builder /out/server ./server
EXPOSE 8080
CMD ["./server"]
Output:
[+] Building 18.4s (12/12) FINISHED
=> [builder 4/6] RUN go mod download
=> [builder 6/6] RUN go build -o /out/server ./cmd/server
=> [final 3/3] COPY --from=builder /out/server ./server
=> exporting to image
=> naming to docker.io/library/go-api:1.0
The final image contains Alpine Linux and /app/server. It does not contain the Go compiler, module cache, source tree, or build output directory. EXPOSE 8080 documents that the app listens on port 8080, but it does not publish the port. You still need docker run -p 8080:8080 go-api:1.0 to reach it from the host.
Example 2: Node Build Stage and Runtime Stage
Many Node applications install dependencies, build static files or server bundles, and then run only the built output. Copying dependency manifests before the source keeps the dependency-install layer reusable when ordinary source files change.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS final
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
Output:
[+] Building 9.7s (13/13) FINISHED
=> [builder 4/6] RUN npm ci
=> [builder 6/6] RUN npm run build
=> [final 4/5] RUN npm ci --omit=dev
=> [final 5/5] COPY --from=builder /app/dist ./dist
=> naming to docker.io/library/node-web:1.0
The builder installs all dependencies because build tools often live in devDependencies. The final stage installs only production dependencies and copies only dist. For even smaller images you may use a distroless Node runtime, but node:20-alpine is simple and familiar for learning.
Example 3: Build a Specific Stage for Debugging
When a build fails, building the builder stage directly can help inspect what happened.
docker build --target builder -t node-web:builder .
docker run --rm node-web:builder npm test
Output:
[+] Building 2.1s (8/8) FINISHED
Successfully tagged node-web:builder
> app@1.0.0 test
> node --test
tests 12
pass 12
The first command tags the intermediate builder stage as an image. The second command runs tests inside that environment. This pattern is useful in CI because the same Dockerfile describes both the build environment and the production runtime.
How It Works Step by Step
- Docker sends the build context to the builder. The context is the files under the build directory after applying
.dockerignore. - BuildKit reads the Dockerfile and starts the first stage from its base image. If the base image layers are missing locally, Docker pulls them from a registry.
- Docker evaluates each instruction in order. For each instruction, it checks whether a cached layer can be reused. If a file copied by
COPYchanged, that layer and all later layers in the same dependency chain must be rebuilt. - When Docker reaches a second
FROM, it starts a new stage with a new base filesystem. Files from the builder stage are not automatically present. COPY --from=builderreads files from the named stage and writes them into the current stage. Only the copied files are included in the final image.- Docker exports the last stage as the tagged image by default. Intermediate stage layers can remain in the local cache so future builds are faster.
This explains why multi-stage builds and cache-friendly ordering belong together. A well-ordered Dockerfile copies dependency manifests first, installs dependencies, then copies application source. Changing one source file should not force Docker to reinstall every dependency.
Common Mistakes
Copying the Whole App Before Installing Dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/server.js"]
This works, but it wastes the cache. Any change to a source file invalidates COPY . ., so Docker must rerun npm ci. The fix is to copy package.json and package-lock.json first, run npm ci, then copy the rest of the source.
Baking Secrets Into a Builder Stage
FROM node:20-alpine AS builder
WORKDIR /app
ENV NPM_TOKEN="<YOUR_NPM_TOKEN>"
RUN npm config set //registry.npmjs.org/:_authToken "$NPM_TOKEN"
COPY package*.json ./
RUN npm ci
This is dangerous even if the final stage does not copy the token. Values used in image layers can leak through build cache, history, logs, or exported intermediate images. A later RUN rm does not erase a secret from an earlier layer. Use BuildKit secrets or your CI system’s secret mounting features instead of putting secrets in ENV or ARG.
Assuming EXPOSE Publishes a Port
docker run --rm node-web:1.0
If the Dockerfile says EXPOSE 3000, this command still does not publish port 3000 to the host. Use a port mapping:
docker run --rm -p 3000:3000 node-web:1.0
Best Practices
- Use multi-stage builds for compiled apps and for apps with large build toolchains.
- Name stages with
AS builder,AS test, orAS finalsoCOPY --fromstays readable. - Pin base image tags such as
node:20-alpineorgolang:1.22-alpine. Avoidlatestfor reproducible builds. - Copy dependency manifests before source files to preserve cache hits.
- Add a
.dockerignorefile so local logs, Git data, test output, and dependency directories do not bloat the build context. - Copy only the artifacts needed at runtime. Do not copy the whole builder filesystem into the final stage.
- Run the final image as a non-root user when the base image and application make that practical.
- Never bake passwords, tokens, SSH keys, or certificates into any stage.
- Use
docker build --targetto debug or test intermediate stages.
Practice Exercises
- You have a Go service with source under
./cmd/api. Write a two-stage Dockerfile that builds the binary ingolang:1.22-alpineand copies only that binary intoalpine:3.20. Hint: place the binary somewhere like/out/apiin the builder. - Rewrite a Node Dockerfile that currently does
COPY . .beforenpm ci. The expected end state is that changes tosrc/index.jsdo not rerun dependency installation. - Add a
teststage to an existing multi-stage Dockerfile. The stage should run tests with development dependencies, while the final stage keeps only production dependencies and built output.
Summary
- A multi-stage Dockerfile has more than one
FROM; eachFROMstarts a separate stage. - The final image usually contains only runtime files copied with
COPY --from=builder. - Builder stages can contain compilers and dev tools without shipping them to production.
- Layer cache behavior still matters: changing a layer invalidates that layer and later layers.
EXPOSEis metadata only; use-por Composeportsto publish a port.- Secrets should not appear in any image layer, even temporary builder layers.
