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
runtimedoesn’t depend ontest, 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 → depsprefix, so the expensivenpm cilayer is computed once. OrderingCOPY package.jsonbeforeCOPY . .keeps dependency layers valid across source edits; that discipline is independent of--targetbut 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 teston every commit and--target runtimeonly 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(ortype=registry,mode=max) exports all layers in the graph, including intermediate compile and test stages. Defaultmode=minonly 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
teststage re-running after only test files change doesn’t invalidatedeps.
Gotchas
- The default target is the last stage, always. Append a scratch “cleanup” stage below
runtimeand your release image silently becomes empty. Put the stage you ship last, or pin--target runtimeeverywhere (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
testisn’t an ancestor of your final stage and you don’t--targetit,npm testnever runs — locally or in CI. Stage-based testing only works if something (a CI step, a release gate) actually targets it. testinheritingCMDfrom a build stage is a common bug. The final stage should pin its ownENTRYPOINT/CMD; don’t assume the inherited one is the runtime command.- Cross-stage
ARGs don’t carry over. DeclareARGinside 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.