Skip to content
Blog

Named Build Stages and --target: Building Just the Layer You Need

Name your Dockerfile stages with FROM ... AS, then build only the stage you need with --target — test images in CI, lean runtime images in release, one file.

Published on • October 31, 2026

AI Assistant

Multi-stage builds are usually sold on image size: compile in a fat image, ship in a thin one. The more interesting capability is what you can do with the middle — because a Dockerfile with several named stages is really a build graph, and --target lets you stop at any node in it.

The multi-stage builds guide makes naming explicit: stages are numbered from 0 by default, and FROM ... AS <name> gives them a stable identity.

Naming stages

# syntax=docker/dockerfile:1
FROM node:22-slim AS base
WORKDIR /app
ENV NODE_ENV=production

FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build

FROM base AS test
ENV NODE_ENV=development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm ci && npm test

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

Two things names buy you:

References survive reordering. COPY --from=build keeps working when someone inserts a new stage above it. The integer form (COPY --from=1) silently points at a different stage the moment the file is edited — the docs call this out directly: name stages “so reordering the file doesn’t break your COPY --from=0.”

Names are self-documenting in --target. docker build --target test reads as intent; docker build --target 3 does not.

Stages can also derive from each other (FROM base AS test), which is how the base → deps → build → test → runtime pattern stays DRY: base carries the workdir and env, deps adds node_modules once, and every later stage inherits from base rather than repeating the setup. Copying from an external image works the same way — COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf pulls that image just to grab one file.

Building with --target

By default a build runs to the last stage in the file. --target stops earlier:

docker build -t myapp:release .               # last stage: runtime
docker build --target test -t myapp:test .    # stop at the test stage
docker build --target build -t myapp:builder . # binary + toolchain, for debugging

The guide lists the scenarios this is for: debugging a specific build stage, producing a debug stage with symbols and tools alongside a lean production stage, and using a testing stage populated with fixture data while production builds from a different stage with real config.

The test-stage pattern is the practical payoff. Tests run inside the build, against the same dependency resolution and environment the release image will use, and a failure aborts the build before anything is tagged or pushed:

docker build --target test -t ci:$GIT_SHA .
# exit code non-zero => CI fails; no image is exported
docker build -t registry.example.com/myapp:$GIT_SHA .

CI vs release

A typical pipeline uses two targets deliberately:

# .github/workflows/ci.yml (excerpt)
- name: Test in image
  run: docker build --target test --build-arg CI=true .

- name: Build release image
  run: |
    docker buildx build \
      --target runtime \
      --cache-from type=gha --cache-to type=gha,mode=max \
      -t registry.example.com/myapp:${{ github.sha }} \
      --push .

Why the split:

  • The test stage never ships. Because runtime doesn’t depend on test, nothing from it — test runner, fixtures, dev dependencies — can leak into the release image, and nobody has to remember to strip it.
  • Cache is shared. Both invocations walk the same base → deps prefix, so the expensive npm ci layer is computed once. Ordering COPY package.json before COPY . . keeps dependency layers valid across source edits; that discipline is independent of --target but pays off more as the graph grows.
  • You can promote the same artifact. Some teams build once (--target build, push the binary as an artifact) and assemble runtime images later. Others run --target test on every commit and --target runtime only on tags. Both are legitimate; what matters is that the runtime image is a derived thing, not the thing you poked at during CI.

Compose picks this up too — the build block accepts target: builder, so a development service can point at a debug stage while production builds the same Dockerfile’s last stage.

BuildKit changes the cost model

With BuildKit (the default builder), only stages the target depends on are built. Given base → stage1 and base → stage2, docker build --target stage2 processes base and stage2 and skips stage1 entirely. The legacy builder was dumber: it processed every stage up to the target, whether or not it was a dependency. The practical consequence is that adding a test or debug stage does not slow down your release build — it isn’t in the release build’s dependency graph.

Two cache notes follow:

  • --cache-to type=gha,mode=max (or type=registry,mode=max) exports all layers in the graph, including intermediate compile and test stages. Default mode=min only exports what the final image uses — which, if you build with --target, may be less than you think.
  • Stage-level cache is keyed on the instructions and inputs of that stage, so a test stage re-running after only test files change doesn’t invalidate deps.

Gotchas

  • The default target is the last stage, always. Append a scratch “cleanup” stage below runtime and your release image silently becomes empty. Put the stage you ship last, or pin --target runtime everywhere (Compose and CI both make this easy) — pinning is the safer habit because it’s explicit.
  • Named stages add zero bytes. A name is metadata for the builder, not a layer. A Dockerfile with five named stages produces exactly the image its final stage describes; the other four exist only during docker build.
  • An unreachable stage is never built. If test isn’t an ancestor of your final stage and you don’t --target it, npm test never runs — locally or in CI. Stage-based testing only works if something (a CI step, a release gate) actually targets it.
  • test inheriting CMD from a build stage is a common bug. The final stage should pin its own ENTRYPOINT/CMD; don’t assume the inherited one is the runtime command.
  • Cross-stage ARGs don’t carry over. Declare ARG inside the stage that uses it.
  • Names don’t validate dependencies. Nothing stops you from COPY --from=typo, and BuildKit reports it at build time — after the earlier stages ran. Keep stage names short and boring (base, deps, build, test, runtime).

When one stage is enough

If your app has no compile step — a static site copied into nginx, a shell script — --target buys little. The pattern starts paying as soon as a build produces something the runtime doesn’t need: bundlers, compilers, linters, test suites. At that point naming the stages and targeting them turns one Dockerfile into a small, cacheable pipeline instead of a single monolithic step.

Further reading