✦ For everyone, free.

Practical knowledge for real and everyday life

Home

17.1.1.3 Multi Stage Best Practice

A focused guide to Multi Stage Best Practice, connecting core concepts with practical Docker and container operations.

Multi-stage best practice goes beyond the basic pattern of separating a build stage from a runtime stage, extending into structuring stages as reusable, parallel, and purpose-specific units that can serve testing, linting, and multiple distinct final targets from a single, well-organized Dockerfile, rather than treating multi-stage builds as only a two-stage build-then-run pattern.

Naming stages for clarity and reuse

Every stage should be named explicitly, even in a simple two-stage build, since named stages are self-documenting and become essential once a Dockerfile grows to include more than a couple of stages referencing each other:

FROM node:20 AS base
WORKDIR /app
COPY package*.json .
RUN npm ci

FROM base AS build
COPY . .
RUN npm run build

FROM base AS production
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

The shared base stage here, containing dependency installation common to both later stages, is built once and reused as the starting point for both build and production, avoiding duplicate dependency installation logic across stages that genuinely share the same starting requirements.

Using a dedicated stage as a test or lint gate

A stage that runs tests or linting, without producing any artifact actually copied forward into the final image, can serve as a build-time quality gate: if that stage fails, the entire build fails, preventing a broken or non-compliant build from ever producing a usable final image at all:

FROM base AS test
COPY . .
RUN npm run lint
RUN npm test

FROM base AS build
COPY . .
RUN npm run build

FROM base AS production
COPY --from=build /app/dist ./dist
docker build --target production -t my-api .

Because the default docker build target is the final stage in the file unless --target specifies otherwise, the test stage here does not run automatically as part of a normal build targeting production; making the test stage an actual, enforced gate requires either making the final stage depend on it explicitly, or running it as an explicit, separate step in the build pipeline before proceeding to build the production target.

FROM test AS build
COPY . .
RUN npm run build

Restructuring so that build is based on test rather than base directly forces the test stage to actually execute (and succeed) as a genuine prerequisite of reaching the build stage, which is the more reliable way to make a quality gate stage actually enforced rather than merely available as an optional, separately invoked target.

Parallel, independent stages

BuildKit executes independent stages in parallel automatically where their dependency graph allows it, which means structuring genuinely unrelated build steps as separate, parallel stages, rather than combining them sequentially within a single stage, can meaningfully reduce overall build time:

FROM node:20 AS frontend-build
WORKDIR /app/frontend
COPY frontend/ .
RUN npm ci && npm run build

FROM golang:1.22 AS backend-build
WORKDIR /app/backend
COPY backend/ .
RUN go build -o server .

FROM alpine:3
COPY --from=frontend-build /app/frontend/dist /var/www
COPY --from=backend-build /app/backend/server /server

Because frontend-build and backend-build have no dependency on each other, BuildKit can execute them simultaneously rather than sequentially, which is a structural decision, organizing genuinely independent work into separate stages, that directly enables this parallelism rather than something that needs to be explicitly requested.

Multiple final targets from one Dockerfile

A single Dockerfile can define several distinct final stages, each serving a different purpose, development, production, or a debug variant with additional tooling, selected explicitly at build time:

FROM base AS development
CMD ["npx", "nodemon", "server.js"]

FROM build AS production
CMD ["node", "dist/server.js"]

FROM production AS debug
RUN apk add --no-cache curl vim
docker build --target development -t my-api:dev .
docker build --target production -t my-api:prod .
docker build --target debug -t my-api:debug .

This pattern keeps a single, shared Dockerfile maintaining consistency across every variant's shared foundation, while still allowing each variant to diverge exactly where it genuinely needs to, without maintaining several entirely separate Dockerfiles that would otherwise drift independently over time.

Avoiding unnecessary stage proliferation

While multiple stages provide real organizational and caching benefits, an excessive number of trivial, single-instruction stages adds complexity without a corresponding benefit; the right number of stages reflects genuinely distinct phases or purposes, not an arbitrary maximization of stage count for its own sake:

FROM base AS install-deps
RUN npm ci

FROM install-deps AS copy-source
COPY . .

FROM copy-source AS run-build
RUN npm run build

This level of fragmentation, splitting what could reasonably be a single, cohesive stage into three trivial ones, adds Dockerfile complexity without a meaningful corresponding benefit in caching granularity or build organization, since these specific steps would cache identically whether combined or separated this finely.

Verifying which target actually gets built by default

Since the default build target is whichever stage appears last in the file, restructuring a Dockerfile by adding a new final stage after the previously-last one silently changes what a build with no explicit --target actually produces, which is worth being deliberate about, particularly in CI pipelines that might not specify a target explicitly and would therefore be affected by this kind of restructuring:

docker build -t my-api .

Explicitly specifying --target in any build pipeline, rather than relying on whichever stage happens to be last in the file, removes this ambiguity and protects against an unintended default target change the next time the Dockerfile is restructured or extended with an additional stage.

Common mistakes

  • Not naming stages explicitly, making a Dockerfile with more than two stages considerably harder to read and reason about.
  • Defining a test or lint stage without actually making the build's final target structurally depend on it, allowing a broken build to bypass the intended quality gate entirely.
  • Writing genuinely independent build steps sequentially within a single stage rather than structuring them as separate, parallel stages BuildKit could otherwise execute simultaneously.
  • Fragmenting a Dockerfile into an excessive number of trivial, single-instruction stages without a corresponding organizational or caching benefit.
  • Relying on the implicit default build target (the last stage in the file) within CI pipelines, rather than specifying --target explicitly, leaving the pipeline vulnerable to an unintended target change whenever the Dockerfile is later restructured.

Multi-stage best practice treats stages as a flexible, structural tool for sharing common setup, enforcing quality gates, enabling parallel execution, and producing multiple distinct final targets from one maintained Dockerfile, applied deliberately based on genuinely distinct build phases and purposes rather than either collapsed into too few stages or fragmented into an unnecessarily large number of trivial ones.