skip to content

questions

5

A Dockerfile that compiles an application and then ships it produces a 900 MB image containing the compiler, package-manager caches and the full source tree. How does putting more than one FROM instruction in a single Dockerfile fix that, and what exactly ends up in the final image?

level: juniorimportance: must knowfreq 80%

answer

  1. Each FROM = new stage, fresh filesystem
  2. COPY --from is the only bridge
  3. Only the last stage ships
  4. rm in a later layer does not shrink an image
  5. Copy the artifact, not the workspace

basics

~20 s

Each FROM starts a new build stage with its own fresh filesystem. Compile in a toolchain stage, then start a slim runtime stage and pull across only the built artifact with COPY --from. Only the last stage's layers ship.

solid answer

~50 s

A Dockerfile can contain several `FROM` instructions. Each one starts a new **stage** with a fresh filesystem; you name it with `AS <name>`. Do the heavy work — install the SDK, fetch dependencies, compile, run tests — in a builder stage. Then start a final stage from a minimal runtime base and copy across only what actually runs: ``` COPY --from=builder /src/build/app.jar /app/app.jar ``` Only the layers of the **last** stage (or the stage selected by `--target`) become the tagged image. Builder layers are never pushed and never pulled by whoever runs the image, so compilers, dependency caches, `.git`, the source tree and any credentials used during the build are simply absent. A 900 MB image typically drops to tens of MB. The payoff is smaller pulls, faster cold starts on autoscaled nodes, and a far smaller surface for vulnerability scanners to flag.

code

dockerfile · 11 lines
dockerfile
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/api

FROM gcr.io/distroless/static:nonroot
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

go deeper

for a junior

Say it plainly: several FROMs, each a stage; build in one, copy the artifact into a slim one with COPY --from; only the last stage ships. Show the Go or JVM shape.

for a middle

Add the layer mechanics — why deleting files later doesn't help, what a whiteout is, and how to keep dependency resolution cacheable inside the builder stage.

for a senior

Frame it operationally: pull time on autoscaled nodes, CVE surface and scanner gates, secret hygiene during dependency fetch, and the libc/CA-cert/debuggability tradeoffs of distroless or scratch runtimes.

for a principal

Talk about it as a fleet policy — a standard base + builder pattern across services, who owns the runtime bases, how debuggability is preserved without shipping shells, and how image size ties to scale-out latency and registry cost.

## What a stage is A Dockerfile is a sequence of instructions, and each `FROM` resets the build: it says "from here on, start from this base image's filesystem". Everything between one `FROM` and the next is a **stage**. Stages are numbered from 0 and can be named with `AS`: ``` FROM golang:1.22 AS builder WORKDIR /src COPY . . RUN go build -o /out/app ./cmd/api FROM gcr.io/distroless/static COPY --from=builder /out/app /app ENTRYPOINT ["/app"] ``` Each stage has its own independent filesystem, its own layers, and its own environment. Nothing leaks sideways between stages automatically — the *only* way content crosses a stage boundary is an explicit `COPY --from=<stage>` (or the equivalent in a later stage). ## What actually ships A built image is a manifest plus an ordered list of filesystem layers. When the build finishes, the image that gets tagged is **the last stage only** — its base layers plus the layers its own instructions produced. The builder stage's layers exist locally in the build cache, but they are not referenced by the final manifest, so they are never pushed to a registry and never pulled by a runtime. This is the crucial mental correction for people coming from single-stage Dockerfiles, where the standard trick was `RUN make && rm -rf /build-deps`. That does not work: a deletion in a later layer only writes a whiteout marker; the deleted bytes still sit in the earlier layer and still travel with the image (and are still recoverable). Multi-stage builds sidestep the whole problem because the fat layers are never part of the shipped chain in the first place. ## The builder pattern The common shape is "toolchain stage → runtime stage": - **Compiled languages** (Go, Rust, C++): build stage has the full SDK; final stage is `scratch` or `distroless/static` holding a single static binary. Images of a few MB are normal. - **JVM**: build stage runs Gradle/Maven with the JDK; final stage uses a JRE or distroless Java base and receives only the jar (or an exploded layered jar). - **Node/Python**: build stage installs dev dependencies, compiles TypeScript or wheels; final stage installs or copies only production dependencies plus the built output. What you copy matters as much as the split. Copy the *artifact*, not the workspace: `COPY --from=builder /src/dist ./dist`, not `COPY --from=builder /src .`, which would drag the whole tree back in and undo the benefit. ## Why this matters beyond size 1. **Security surface.** No compiler, no `curl`, no shell (with distroless/scratch) means an attacker who achieves code execution has far fewer tools to pivot with. Fewer OS packages also means far fewer CVEs reported against the image, which is a real operational cost when a scanner gates deploys. 2. **Secret hygiene.** Private-registry tokens, SSH keys or `.npmrc` files used to fetch dependencies live in the builder stage and never reach the shipped layers. (Build-time secrets have a dedicated safer mechanism, but stage separation already removes the most common leak.) 3. **Reproducibility and CI simplicity.** The build no longer depends on the CI runner having the right SDK installed — the Dockerfile carries the toolchain. `docker build` on a laptop produces the same artifact as CI. 4. **Pull time.** Cold starts on autoscaling nodes are dominated by image pull. Cutting 900 MB to 60 MB is often the single biggest scale-out latency win available. ## Common pitfalls - **Forgetting runtime dependencies.** A binary built against glibc will not run on `scratch` or on Alpine's musl. Either build statically, use `distroless/base` (which carries glibc), or match libc between stages. The failure mode is a cryptic "no such file or directory" when the *binary* exists — that message really means the dynamic loader is missing. - **Missing supporting files.** CA certificates, timezone data, locale files and the app's static assets all have to be copied explicitly. - **Copying too much.** `COPY --from=builder / /` defeats the purpose. - **No non-root user.** Slimness is not hardening; the final stage still needs a `USER` that is not root. - **Losing debuggability.** With no shell, `docker exec sh` is impossible. The usual answer is a debug variant stage plus ephemeral debug containers at runtime, not shipping a shell to production. ## Ordering inside stages Stages compose with normal layer caching: put the things that rarely change (dependency manifests, `go.mod`, `package.json`) early, so a source-only edit rebuilds only the compile step. This matters more in multi-stage builds because the expensive dependency resolution lives in the builder stage and can be cached independently of the runtime stage.

  • Do the builder stage's layers still exist anywhere after the build, and can someone extract source code from the pushed image?
    Locally, yes — the builder layers remain in the build cache on the machine that built the image, which is why cache pruning matters on shared runners. But the pushed image manifest references only the final stage's layers, so nothing from the builder stage is in the registry or on the runtime host. Anyone pulling the image gets only what the final stage contains.
  • Why doesn't `RUN make && rm -rf /toolchain` in a single-stage Dockerfile achieve the same size reduction?
    Layers are stacked copy-on-write filesystems. Deleting a file in a later layer only records a whiteout entry that hides it; the original bytes stay in the earlier layer and are still shipped and still extractable. Only removing files inside the *same* RUN that created them avoids that, and even then the toolchain packages themselves remain. Multi-stage builds avoid it entirely because those layers are never part of the final image.
  • How would you still run tests as part of the image build if the test tooling isn't in the final image?
    Add a stage that starts from the builder and runs the test command; because a failing RUN fails the build, the tests gate the image. In CI you build that stage explicitly with `--target test`, or you make the final stage depend on a file the test stage produced so it cannot be skipped.

A workshop and a display case. You need saws, sawdust and offcuts to make the chair, but only the chair goes in the case — the customer never receives your workshop.

saying these in an interview costs you the question

  • Believing `rm -rf` in a later layer shrinks the shipped image
  • Thinking builder-stage layers are pushed to the registry and just hidden
  • Copying the whole build workspace across with COPY --from, keeping the image fat
  • Assuming a dynamically linked binary will run on scratch or Alpine without matching libc
  • Treating a small image as automatically secure and skipping a non-root USER

context

open as a page

Besides a previously declared build stage, what else can the `--from` flag on a Dockerfile COPY instruction refer to, and what are the rules for naming and referencing stages?

level: middleimportance: should knowfreq 55%

basics

~20 s

COPY --from accepts a stage name declared with FROM ... AS name, a stage index (--from=0), or any external image reference such as --from=nginx:1.27. Source paths are resolved inside that stage or image, not in the build context.

open as a page

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%

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.

open as a page

How does splitting a Dockerfile into several build stages change build parallelism and layer-cache invalidation compared with one long linear Dockerfile, and how should you order instructions inside each stage to benefit?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Stages form a dependency graph, not a list. Independent stages build concurrently, unreferenced stages are skipped, and a cache miss invalidates only the rest of that stage's chain — not sibling stages. Inside each stage, put rarely-changing steps first.

open as a page

For a fleet of services built in CI, when is a single multi-stage Dockerfile per service the right structure, versus a shared prebuilt builder image, versus having an external build system hand the Dockerfile a finished artifact? What tradeoffs drive that call?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Self-contained multi-stage Dockerfiles maximise reproducibility and local parity but rebuild toolchains everywhere. A shared prebuilt builder image centralises the toolchain and speeds cold builds at the cost of a versioned dependency. Copying in an externally built artifact is fastest but loses hermeticity.

open as a page