skip to content

You need a development image containing test tooling and a lean production image, both from one Dockerfile. How would you structure the stages, and which `docker build` flag selects which image you get?

level: middleimportance: should knowfreq 50%

answer

  1. --target stops at a named stage
  2. No --target = last stage in file
  3. Put prod last so plain builds are safe
  4. compose build.target for dev
  5. Skipped stages do not gate — build test explicitly

basics

~20 s

Layer the stages: a shared base, a deps stage, a dev/test stage with tooling, and a lean final stage. Build a specific one with docker build --target dev. Without --target the last stage in the file is built, so keep production last.

solid answer

~60 s

Structure the Dockerfile as a small graph rather than a straight line: ``` FROM node:22 AS base FROM base AS deps # npm ci (all deps) FROM deps AS dev # test tooling, CMD npm run dev FROM deps AS test # RUN npm test FROM base AS prod # prod deps + built output only ``` Then `docker build --target dev -t app:dev .` and `docker build -t app:prod .`. `--target` stops the build at the named stage and tags *that* stage. With no `--target`, the **last stage in the file** is built — so the production stage goes last, which also makes an accidental plain `docker build` safe. Compose supports the same via `build: { target: dev }`, so `docker compose up` gives developers the dev image while CI builds the default. Two important consequences: stages outside the target's dependency closure are never executed, so a `test` stage does **not** gate a production build unless CI builds it explicitly; and dev and prod genuinely share base and dependency layers, which keeps drift low.

code

dockerfile · 25 lines
dockerfile
FROM node:22-slim AS base
WORKDIR /app

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

FROM deps AS dev
ENV NODE_ENV=development
CMD ["npm","run","dev"]

FROM deps AS test
COPY . .
RUN npm run lint && npm test

FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev

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

go deeper

for a junior

Know that --target <stage> builds that stage and that without it you get the last stage in the file. Show a two-variant example.

for a middle

Explain the shared base/deps trunk, why it beats a second Dockerfile, and how compose's build.target wires the dev variant.

for a senior

Lead with the gating trap — skipped stages don't run tests — and give both fixes; cover debug variants, --output for artifact export, and shared-layer cache behaviour in CI.

for a principal

Treat it as a platform contract: standard stage names across services so CI templates can assume --target test, production last by policy, and a stance on how debug access is provided without shipping tools.

## Why one Dockerfile instead of two Two Dockerfiles (`Dockerfile` and `Dockerfile.dev`) drift. The base image tag gets bumped in one and not the other, the dependency install differs subtly, and "works in dev" stops meaning anything. A multi-stage Dockerfile with named stages lets both variants share the same base and the same dependency-resolution step, with the differences expressed as a handful of extra instructions. ## The shape Think of the stages as a small graph with a shared trunk: ``` base → deps → dev ↘ test base → prod (copies build output from a builder) ``` - **base**: pinned base image, OS packages needed by everything, `WORKDIR`, non-root user. - **deps**: dependency manifests copied and resolved. Isolating this is what makes the layer cache useful — source edits do not re-resolve dependencies. - **dev**: adds debuggers, watchers, shell utilities; `CMD` runs the hot-reload server. Intended to be run with the source bind-mounted. - **test**: runs linters and the test suite as `RUN` steps, so a failure fails the build. - **prod**: minimal runtime base, receives only the built artifact and production dependencies, non-root `USER`, real `ENTRYPOINT`. ## --target semantics `docker build --target <stage>` builds the dependency closure of that stage and tags the stage's own result. Everything not needed by that stage is skipped entirely — BuildKit resolves the graph first, so unreferenced stages produce no work at all. With no `--target`, the build produces the **last stage in the file**. That single rule drives a convention: put `prod` last, so a naive `docker build .` (a new engineer, a default CI template, a `docker build` in a script) yields the safe image and never accidentally ships an image with debuggers and dev dependencies. Compose expresses the same selection declaratively: ```yaml services: api: build: context: . target: dev volumes: - ./src:/app/src ``` ## The trap: skipped stages do not gate Because BuildKit only builds what the target needs, a `test` stage sitting in the file is inert during a production build. Candidates frequently assume tests "run as part of the image build". They do not, unless one of these is true: 1. CI runs `docker build --target test .` as its own step (the common, explicit answer), or 2. the production stage copies something the test stage produced — e.g. the test stage writes `/out/tests-passed` and `prod` does `COPY --from=test /out/tests-passed /tmp/` — creating a real graph edge so the tests cannot be skipped. Option 1 is clearer and gives better CI output; option 2 is what you reach for when you must guarantee the coupling. Either way, say out loud which one you chose and why. ## Extra variants worth knowing - **debug variant of production**: a stage `FROM prod AS prod-debug` that adds a shell and diagnostic tools, built only when someone needs to investigate. Production stays shell-free. - **artifact export**: `docker build --target artifacts --output type=local,dest=./out .` writes files to the host instead of producing an image — handy for extracting coverage reports or a compiled binary from CI without keeping an image around. - **matrix bases**: combine `--target` with `ARG BASE_TAG` to build the same stage against several runtime bases. ## Caching and CI behaviour Because dev, test and prod share `base` and `deps`, a CI run that builds test and then prod reuses the dependency layers rather than resolving twice. This only holds if the shared work is genuinely in a shared ancestor stage — copy-pasting `npm ci` into three stages produces three independent, separately-cached executions. When CI runners are ephemeral, remember that only the final stage's layers are exported to a registry cache by default; keeping intermediate stage cache requires an explicit max-mode cache export. ## Reviewing it A good `--target` layout is legible: stage names are nouns a reader recognises (`base`, `deps`, `test`, `prod`), production is last, no stage is reachable-but-unused, and the diff between dev and prod is a few visible lines rather than a whole second file.

  • You added a `test` stage that runs the suite, but a production build with no `--target` succeeds even when tests fail. Why?
    BuildKit builds only the dependency closure of the target stage, and with no `--target` the target is the last stage in the file. If nothing in that closure references the `test` stage, it is never executed. Fix it either by running `docker build --target test` as an explicit CI step, or by making the production stage COPY an artifact produced by the test stage so the graph edge forces it to run.
  • How do you get a debug shell into a production image that deliberately contains no shell?
    Don't add one to the production stage. Either build a separate `FROM prod AS prod-debug` stage that layers in a shell and diagnostic tools and run that image when investigating, or attach an ephemeral debug container that shares the target's namespaces at runtime. Both keep the shipped production image free of tools an attacker could reuse.

saying these in an interview costs you the question

  • Assuming a `test` stage runs during every build regardless of target
  • Thinking --target picks a stage to *skip* rather than the stage to stop at and tag
  • Putting the dev stage last, so a plain `docker build .` ships dev tooling
  • Maintaining a separate Dockerfile.dev that silently drifts from production
  • Duplicating the dependency-install step into each variant, losing shared cache

context