---
title: "09 — Building Images: Dockerfiles, Layer Caching, and Multi-Stage Builds"
uid: building-container-images
tags: ["dockerfile", "layer-cache", "roadmap:docker", "docker", "multi-stage", "images", "buildx"]
excerpt: "Every Dockerfile instruction is a cached layer, reused only if nothing above it changed. Ordering matters — a one-line change can rebuild everything or just the top slice."
date: 2026-08-13T03:28:14+0000
source: https://www.aveshina.my.id/en/blog/building-container-images
---

Every code change triggered a five-minute rebuild, and I couldn't see why until I found the rule that governs almost all build performance: **every Dockerfile instruction is a cached layer, and a layer is reused only if nothing above it changed — so the order of instructions determines whether a one-line code change rebuilds the whole image or just the top slice.** [1][2]

The framing worth holding onto is that building images is two problems layered on top of each other. The first is *correctness* — does the Dockerfile produce an image that runs the app. The second is *efficiency* — does it rebuild fast, and is the resulting image small. The same instruction ordering solves both, because the cache and the size both flow from how I structure my layers [1][3].

## Layers and the cache

Every instruction in a Dockerfile (FROM, RUN, COPY, ADD, etc.) creates one new layer in the image [1][3]. When I rebuild, Docker walks the file top to bottom and, for each instruction, checks whether its *inputs* match the cached version. If they match, it reuses the cached layer and skips the work. The moment it finds an instruction whose inputs have changed, it rebuilds that layer — and **every layer below it** [2].

The implication is the whole game. Consider two Dockerfiles that install dependencies then copy source:

```
# BAD — code change busts the dependency cache
FROM node:20
WORKDIR /app
COPY . .                  # any source change invalidates everything below
RUN npm ci                # re-installs 800 packages on every code change
CMD ["node", "server.js"]
```

```
# GOOD — dependencies cached unless package.json changes
FROM node:20
WORKDIR /app
COPY package*.json ./     # only re-runs npm ci when package.json changes
RUN npm ci
COPY . .                  # source changes only bust from here up
CMD ["node", "server.js"]
```

The difference is enormous. In the bad version, editing one line of server.js changes the input to COPY . ., which busts npm ci, so every rebuild reinstalls every dependency. In the good version, package.json rarely changes, so npm ci is cached almost forever, and source edits only invalidate the thin top layer [2][3]. The rule, stated plainly: **copy the thing that changes least often first; copy the thing that changes most often last.**

## What invalidates a layer

The cache key for each instruction depends on the instruction text *and* its inputs [2]:

- RUN <command> — keyed on the command string. Same string, cached.
- COPY src dst — keyed on the checksums of the source files. Change a file, bust the layer.
- ADD — same as COPY, plus URL/archive handling.
- Anything ARG or ENV used in a later instruction — changing the variable busts everything downstream.

This is why pinning versions in RUN apt-get install -y curl=7.88.1-1 matters in two directions: it makes builds reproducible *and* it makes the cache key stable, so an unrelated change doesn't re-trigger the install.

## Reducing image size

The same layer-thinking that speeds up builds also shrinks images. Every layer is permanent — even if a later layer deletes a file an earlier layer added, the earlier layer still carries those bytes in the image's history [3][4]. So the strategies all aim to avoid bloating layers in the first place:

- **Use minimal base images.** node:20-alpine is tens of megabytes; node:20 is hundreds. Alpine uses musl instead of glibc, which occasionally breaks native modules, but for most apps the size win is worth checking.
- **Combine cleanup into the same RUN.** Because layers are additive, RUN apt-get install -y curl followed by RUN rm -rf /var/lib/apt/lists/* still ships the apt cache in the first layer. The cleanup has to be in the *same* RUN, chained with &&, so the bloat never lands in a committed layer [3]:

  ``dockerfile   RUN apt-get update \       && apt-get install -y --no-install-recommends curl \       && rm -rf /var/lib/apt/lists/*   ``

- **Multi-stage builds** — the big one.

## Multi-stage builds: build fat, ship thin

The most powerful size-reduction technique is the **multi-stage build**: use a heavy "builder" stage with all the compilers and build tools, then copy only the produced artifact into a tiny final image [4]. The build tools never make it into the image I ship.

```
# stage 1 — builder, has the compiler/toolchain
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/server ./cmd/server

# stage 2 — final, has only the binary
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/server /server
ENTRYPOINT ["/server"]
```

The builder stage is hundreds of megabytes (the Go toolchain, the source, module cache). The final image contains *only* the compiled binary — single-digit megabytes — because COPY --from=builder pulls in just the artifact, not the stage that built it [4]. The same pattern works for any compiled language: a Node app's final stage can be node:20-alpine carrying just dist/ and node_modules/, without the dev dependencies or TypeScript used to build them.

```figure
<svg viewBox="0 0 720 260" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="A multi-stage build. Left: a fat builder image containing the Go toolchain, source, and module cache. An arrow labeled COPY --from=builder pulls only the compiled binary out of it. Right: a tiny distroless final image containing just the server binary. Size labels: 900MB on the left, 12MB on the right.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- Builder -->
    <rect x="40" y="40" width="240" height="180" rx="10" fill="#fee2e2" stroke="#dc2626" stroke-width="1.5"/>
    <text x="160" y="62" font-size="12" font-weight="700" fill="#7f1d1d" text-anchor="middle">builder stage (golang:1.22)</text>
    <g font-size="10" fill="#7f1d1d">
      <rect x="60" y="78"  width="200" height="20" rx="4" fill="#fecaca" stroke="#dc2626"/>
      <text x="160" y="92" text-anchor="middle">Go toolchain + libs</text>
      <rect x="60" y="104" width="200" height="20" rx="4" fill="#fecaca" stroke="#dc2626"/>
      <text x="160" y="118" text-anchor="middle">source + module cache</text>
      <rect x="60" y="130" width="200" height="20" rx="4" fill="#fecaca" stroke="#dc2626"/>
      <text x="160" y="144" text-anchor="middle">build tools (git, gcc…)</text>
      <rect x="60" y="156" width="200" height="20" rx="4" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
      <text x="160" y="170" font-weight="700" fill="#052e16" text-anchor="middle">/out/server  (binary)</text>
    </g>
    <text x="160" y="205" font-size="11" font-weight="700" fill="#7f1d1d" text-anchor="middle">~900 MB</text>

    <!-- arrow -->
    <defs><marker id="marr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#16a34a"/></marker></defs>
    <path d="M290,110 L420,110" fill="none" stroke="#16a34a" stroke-width="1.8" marker-end="url(#marr)"/>
    <text x="355" y="100" font-size="10" font-weight="700" fill="#052e16" text-anchor="middle">COPY --from=builder</text>
    <text x="355" y="124" font-size="9" fill="#475569" text-anchor="middle">only the artifact</text>

    <!-- Final -->
    <rect x="430" y="40" width="240" height="180" rx="10" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="550" y="62" font-size="12" font-weight="700" fill="#052e16" text-anchor="middle">final stage (distroless)</text>
    <rect x="450" y="120" width="200" height="40" rx="6" fill="#86efac" stroke="#16a34a" stroke-width="1.5"/>
    <text x="550" y="144" font-size="11" font-weight="700" fill="#052e16" text-anchor="middle">/server  (binary only)</text>
    <text x="550" y="205" font-size="11" font-weight="700" fill="#052e16" text-anchor="middle">~12 MB</text>
  </g>
</svg>
```

The final image is small, has no compiler for an attacker to abuse, and starts fast because there's nothing in it to load. Multi-stage is the single highest-leverage change I make to any real Dockerfile [4].

## Buildx and the modern builder

The default docker build has been quietly replaced under the hood by **BuildKit** via the buildx CLI, and it's worth knowing because it unlocks the features above plus more: parallel building of independent stages, better caching (including remote cache export/import between machines), and multi-platform builds (one command produces an image that runs on both amd64 and arm64) [1]. The syntax is unchanged — docker build invokes BuildKit by default in current Docker — but docker buildx build exposes the advanced flags when I need them.

## How I use this

The Dockerfile template I default to encodes everything above. Dependencies copied and installed before source. RUN steps chained so cleanup shares the layer. A multi-stage build the moment the app is compiled or has dev-only tooling. Versions pinned in RUN installs for reproducibility and stable cache keys. When a build feels slow, I run docker build --progress=plain to watch which layers are CACHED and which rebuild — the first non-cached line is the cache-busting instruction I need to reorder. Treating the Dockerfile as a cache-and-size optimization problem, not just a correctness one, is what made my image builds tolerable.

## References

[1] Docker, Inc., "Docker Build — Overview," Docker Docs, 2024. [Online]. Available: [https://docs.docker.com/build/concepts/overview](https://docs.docker.com/build/concepts/overview)

[2] Docker, Inc., "Docker Build cache," Docker Docs, 2024. [Online]. Available: [https://docs.docker.com/build/cache/](https://docs.docker.com/build/cache/)

[3] Docker, Inc., "Dockerfile best practices," Docker Docs, 2024. [Online]. Available: [https://docs.docker.com/develop/develop-images/dockerfile_best-practices/](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/)

[4] Docker, Inc., "Multi-stage builds," Docker Docs, 2024. [Online]. Available: [https://docs.docker.com/build/building/multi-stage/](https://docs.docker.com/build/building/multi-stage/)

[5] Docker, Inc., "Dockerfile reference," Docker Docs, 2024. [Online]. Available: [https://docs.docker.com/engine/reference/builder/](https://docs.docker.com/engine/reference/builder/)

[6] Docker, Inc., "docker buildx build reference," Docker Docs, 2024. [Online]. Available: [https://docs.docker.com/reference/cli/docker/buildx/build/](https://docs.docker.com/reference/cli/docker/buildx/build/)

```quiz
Q: Why does the "bad" Dockerfile (COPY . . before RUN npm ci) reinstall all dependencies on every code change?
- Because npm ci ignores the cache
- Because the COPY layer's inputs changed, which busts that layer and every layer below it — including npm ci
correct: 1
explain: Docker's cache is ordered. Once an instruction's inputs change, it and all following instructions are rebuilt. Putting the volatile COPY before the expensive RUN guarantees a full reinstall on every change.

Q: The rule for instruction ordering in a Dockerfile is…
- copy what changes most often first, what changes least often last
- copy what changes least often first (e.g. package.json), what changes most often last (e.g. source)
correct: 1
explain: Stable inputs early keep their layers cached. Volatile inputs last means a change only busts the thin top of the image, not the expensive dependency-install layers.

Q: Why must `apt-get install` and `rm -rf /var/lib/apt/lists/*` be in the SAME RUN instruction?
- Because apt requires root
- Because layers are additive — a separate cleanup RUN still ships the apt cache in the earlier layer
correct: 1
explain: Each RUN commits a layer. If the install is its own layer, the downloaded packages live in that layer permanently, even if a later layer deletes them. Chaining with && ensures the bloat never lands.

Q: A multi-stage build reduces final image size by…
- compressing layers better than single-stage builds
- using a heavy builder stage to produce an artifact, then copying only that artifact into a minimal final image
correct: 1
explain: COPY --from=builder pulls only the named files out of the builder stage. The toolchain, source, and build tools stay in the builder and never reach the final image, which can be tiny.

Q: What does `docker buildx build` give you over the legacy builder?
- the ability to build images without a Dockerfile
- parallel stage building, advanced/remote caching, and multi-platform (amd64 + arm64) builds in one command
correct: 1
explain: BuildKit (invoked via buildx) adds parallel independent-stage builds, richer cache export/import, and multi-architecture output. The Dockerfile syntax is unchanged.
```
