---
title: 'Multi-Stage Build'
source: 'https://academia.sh/en/courses/containers/multi-stage-build'
course: Containers
language: en
updated: '2026-08-23T16:55:08+00:00'
license: 'CC BY-SA 4.0'
---

# Multi-Stage Build

Separating build tools from the output is measured: the same application is built single-stage and multi-stage, the final image's size, layer count, and count of unneeded files left inside are compared, and multi-stage building's cost is counted too — total bytes produced, unshipped intermediate layers, and cache behavior.

The previous lesson built the cache that removes repeated work and counted the cost of instruction
order. Throughout that measurement, the image's content was never touched: the stack that emerged
at the end of eight builds was identical to what the first build produced. Every file the
dependency install left behind, every source file copied, and every tool used during install sat
in the final layer.

Yet part of these is only needed during the build. A compiler, a linker, a test runner, and dev
dependencies are used to produce the output; once the output is produced, none of them run again.
**Multi-stage building** sets up this separation at the instruction level: the build happens in
its own stage, and only the produced files travel to the final image. The question to measure is
clear — how far apart are the final image's size, layer count, and count of unneeded files left
inside it, and what is the cost of this separation?

**IM19.** The interpreter below is an extended form of the previous lessons' model: a `stage`
instruction is added, and when a `copy` source is written as `<stage>:<path>`, it reads from an
earlier stage's view. **IM20.** The build base adds three build tools on top of the runtime base;
the difference between the two bases is written explicitly in the model. **IM21.** The bundling
model merges non-test source files and runtime dependencies into a single file; dev dependencies
do not enter the bundle. **IM22.** The files needed at runtime are defined by hand: the runtime
base's files and the bundled output. The fictional measurement network's reading collector service
is measured.

## Build Time Versus Runtime

In the single-stage definition, all instructions write to a single stack, and that stack both does
the build and gets run. In the multi-stage definition, the `stage` instruction splits the stack:
each stage starts from its own base, builds its own chain, and the final stage takes only the
paths it wants from earlier stages. The final image is the final stage's layers; earlier stages'
layers never enter the image at all.

The separation has a counterpart on the chain too. Every `stage` instruction resets the key chain:
the second stage's base layer gets a key independent of whatever happened in the first stage, and
it comes from the cache even when the source changes. The only link that crosses between the two
stages is the content of the files carried across. The previous lesson's rule still holds here — a
layer's key covers everything beneath it — but "everything beneath it" is now cut off at the stage
boundary. This is the single structural distinction that makes multi-stage building's cache
behavior both better and worse: the second stage's base comes for free, but the copy layer gets
rebuilt every time the carried package changes.

```js
// measurement-network/stage.mjs — staged-build interpreter and layer cache (model)
import { mkdirSync, writeFileSync, readFileSync, readdirSync, appendFileSync, rmSync } from "node:fs";
import { createHash } from "node:crypto";

const ROOT = "project";
const digest = (s) => createHash("sha256").update(s).digest("hex").slice(0, 12);
const text = (name, n) => Array.from({ length: n }, (_, i) => `// ${name} ${i}\n`).join("");

const TREE = { "source/entry.js": text("entry", 22), "source/collect.js": text("collect", 150),
  "source/queue.js": text("queue", 96), "source/test/collect.test.js": text("test", 205) };

const RUNTIME_BASE = { "root/runtime.js": text("runtime", 400), "root/shell.js": text("shell", 55) };
// Build base adds build tools on top of the runtime base.
const BUILD_BASE = { ...RUNTIME_BASE, "tool/compiler.js": text("compiler", 3200),
  "tool/linker.js": text("linker", 1450), "tool/headers.js": text("headers", 900) };

// Bundling: non-test source and runtime dependencies merge into a single file.
const bundle = (view) => [...view].filter(([y]) =>
  (y.startsWith("work/source/") && !y.includes("/test/")) || y.startsWith("dependency/runtime/"))
  .sort().map(([, i]) => i).join("");

const OUTPUT = {
  "build-base": () => BUILD_BASE,
  "runtime-base": () => RUNTIME_BASE,
  "install-deps": () => ({ "dependency/dev/test-runner.js": text("test-runner", 2100),
    "dependency/dev/build-plugin.js": text("build-plugin", 1600),
    "dependency/runtime/csv.js": text("csv", 780), "dependency/runtime/queue.js": text("queue-dep", 640) }),
  "package-build": (view) => ({ "work/dist/package.js": bundle(view) }),
};

export const SINGLE = `stage     single
base      build-base
copy      source  /work/source
run       install-deps
run       package-build
entry     /work/dist/package.js`;

export const MULTI = `stage     builder
base      build-base
copy      source  /work/source
run       install-deps
run       package-build
stage     output
base      runtime-base
copy      builder:/work/dist  /app
entry     /app/package.js`;

// Same multi-stage definition, in the form that moves the whole work directory across.
export const MULTI_WIDE = MULTI.replace("builder:/work/dist  /app", "builder:/work  /app")
  .replace("entry     /app/package.js", "entry     /app/dist/package.js");

const writeFile = (path, content) => {
  mkdirSync(path.split("/").slice(0, -1).join("/"), { recursive: true });
  writeFileSync(path, content);
};
export const buildTree = () => {
  rmSync(ROOT, { recursive: true, force: true });
  rmSync("image", { recursive: true, force: true });
  for (const [y, i] of Object.entries(TREE)) writeFile(`${ROOT}/${y}`, i);
};
export const touch = (path, no) => appendFileSync(`${ROOT}/${path}`, `// fix ${no}\n`);

const walk = (sub = "") => readdirSync(`${ROOT}/${sub}`, { withFileTypes: true }).flatMap((g) =>
  g.isDirectory() ? walk(`${sub}/${g.name}`) : [`${sub}/${g.name}`.replace(/^\//, "")]).sort();

// Pairs to copy: from the tree, or from another stage's view.
const copyPairs = (source, target, stages) => {
  if (!source.includes(":")) return walk(source).map((y) =>
    [`${target.slice(1)}/${y.slice(source.length + 1)}`, readFileSync(`${ROOT}/${y}`).toString()]);
  const [name, path] = source.split(":");
  const prefix = `${path.slice(1)}/`;
  return [...stages.get(name).view].filter(([y]) => y.startsWith(prefix))
    .map(([y, i]) => [`${target.slice(1)}/${y.slice(prefix.length)}`, i]);
};

export const build = (definition, cache = new Map()) => {
  const stages = new Map();
  let current = null, key = "";
  const counter = { builds: 0, hits: 0, bytes: 0 };
  for (const raw of definition.trim().split("\n")) {
    const [instruction, source, target] = raw.trim().split(/\s+/);
    if (instruction === "stage") {
      current = { name: source, view: new Map(), layer: 0 };
      stages.set(source, current); key = ""; continue;
    }
    if (instruction === "entry") continue; // metadata: produces no layer
    const input = instruction === "copy" ? copyPairs(source, target, stages).join() : "";
    key = digest(key + raw + input);
    let pairs;
    if (cache.has(key)) { pairs = cache.get(key); counter.hits++; }
    else {
      pairs = instruction === "copy" ? copyPairs(source, target, stages)
        : Object.entries(OUTPUT[source](current.view));
      for (const [y, i] of pairs) writeFile(`image/${current.name}/layer-${current.layer}/${y}`, i);
      cache.set(key, pairs);
      counter.builds++; counter.bytes += pairs.reduce((t, [, i]) => t + i.length, 0);
    }
    current.layer++;
    for (const [y, i] of pairs) current.view.set(y, i);
  }
  return { stages, counter, last: current };
};

// Needed at runtime: runtime base files and the packaged output.
export const needed = (path) => path.startsWith("root/") || path.endsWith("package.js");
```

## Two Builds, One Application

Both definitions produce the same bundle from the same source. What is measured is what is left
sitting alongside the bundle.

```js
// measurement-network/measure-image.mjs — compares single-stage and multi-stage builds' final images
import { buildTree, build, needed, SINGLE, MULTI, MULTI_WIDE } from "./stage.mjs";

const inspect = (label, definition) => {
  buildTree();
  const { stages, counter, last } = build(definition);
  const files = [...last.view];
  const unneeded = files.filter(([y]) => !needed(y));
  const bytes = files.reduce((t, [, i]) => t + i.length, 0);
  const mid = [...stages.values()].filter((a) => a !== last);
  console.log(label.padEnd(20) + String(last.layer).padStart(4) + String(files.length).padStart(7) +
    String(bytes).padStart(9) + String(unneeded.length).padStart(9) +
    String(unneeded.reduce((t, [, i]) => t + i.length, 0)).padStart(9) + String(counter.bytes).padStart(10) +
    String(mid.reduce((t, a) => t + a.layer, 0)).padStart(6));
  return { bytes, files, unneeded };
};

console.log("build".padEnd(20) + " lyr  files     bytes  unneeded     bytes   produced mid-lyr");
const t = inspect("single-stage", SINGLE);
const c = inspect("multi-stage", MULTI);
inspect("multi-stage, wide", MULTI_WIDE);
console.log(`\nfinal image: ${t.bytes} -> ${c.bytes} bytes, ${(t.bytes / c.bytes).toFixed(1)}x smaller`);
console.log(`files in final image: ${t.files.length} -> ${c.files.length}`);
console.log(`unneeded at runtime: ${t.unneeded.length} -> ${c.unneeded.length}`);
console.log("unneeded files in single-stage: " + t.unneeded.map(([y]) => y).join(" "));
```

```
build                lyr  files     bytes  unneeded     bytes   produced mid-lyr
single-stage           4     14   215142       11   185826    215142     0
multi-stage            2      3    29316        0        0    244458     4
multi-stage, wide      2      7    35202        4     5886    250344     4

final image: 215142 -> 29316 bytes, 7.3x smaller
files in final image: 14 -> 3
unneeded at runtime: 11 -> 0
unneeded files in single-stage: tool/compiler.js tool/linker.js tool/headers.js work/source/collect.js work/source/entry.js work/source/queue.js work/source/test/collect.test.js dependency/dev/test-runner.js dependency/dev/build-plugin.js dependency/runtime/csv.js dependency/runtime/queue.js
```

The final image drops from 215,142 bytes to 29,316 bytes, file count from 14 to 3, layer count
from 4 to 2. The single-stage image's 11 files and 185,826 bytes never run at runtime at all —
86.4 percent of the image. Among them are three build tools, four source files (one a test file),
two dev dependencies, and two runtime dependencies that have already entered the bundle. The
runtime dependencies being carried twice is also counted: they sit both inside the bundle and
alongside it.

The drop in layer count needs a separate reading too. The multi-stage image has two layers because
the final stage runs only two instructions; this also changes the sharing gain the previous lesson
measured. Other services born from the same base can share the first of those two layers; the
second is specific to the service. In the single-stage image, the shareable base also carries the
build tools, so it is both bigger and invalidates whenever the build tool version changes — the
sharing gain depends on how often something changes, and the build base changes more often than
the runtime base.

The difference pulled out of the environment is this lesson's biggest item. The compiler, the
linker, and the header files leave the runtime environment entirely; the running container's
filesystem has no build tool in it. At the instruction level, this costs two lines — a second
`stage` and a single `copy` between stages.

The third row separates where the gain comes from. When the same multi-stage definition carries
across the whole `/work` directory instead of `/work/dist`, the final image grows to 35,202 bytes
and 7 files; four of them are source files not needed at runtime and take up 5,886 bytes. The
whole gain does not come from opening a second stage; it comes from **the narrowness of the path
carried across**. A second stage only helps to the extent that a decision is made about what it
will **not** take from the first stage. This is the stage-boundary form of the directory copying
problem measured in the previous lesson: `copy` takes a path, it does not count what sits beneath
that path.

## Multi-Stage's Cost

The separation is not free. The same two definitions are run twice with one cache: the first build
with a cold cache, the second after a single line is added to one source file. **IM23.** The
change adds one line to `source/collect.js`. **IM24.** The stage view is kept in memory, layers
are written to disk; the bytes measured are real file bytes.

```js
// measurement-network/cost.mjs — multi-stage build's cost: intermediate layers and cache behavior
import { buildTree, touch, build, SINGLE, MULTI } from "./stage.mjs";

const sequence = (label, definition) => {
  buildTree();
  const cache = new Map();
  const first = build(definition, cache).counter;
  touch("source/collect.js", 1);
  const { stages, counter, last } = build(definition, cache);
  const mid = [...stages.values()].filter((a) => a !== last)
    .reduce((t, a) => t + [...a.view.values()].reduce((u, i) => u + i.length, 0), 0);
  const cacheBytes = [...cache.values()].reduce((t, c) => t + c.reduce((u, [, i]) => u + i.length, 0), 0);
  console.log(label.padEnd(14) + String(first.builds).padStart(4) + String(first.bytes).padStart(9) +
    String(counter.hits).padStart(8) + String(counter.builds).padStart(8) + String(counter.bytes).padStart(9) +
    String(cache.size).padStart(8) + String(cacheBytes).padStart(9) + String(mid).padStart(10));
};

console.log("build".padEnd(14) + " 1st  1st-byt   hits  builds    bytes   cache    bytes  unshipped");
sequence("single-stage", SINGLE);
sequence("multi-stage", MULTI);
```

```
build          1st  1st-byt   hits  builds    bytes   cache    bytes  unshipped
single-stage     4   215142       1       3   121300       7   336442         0
multi-stage      6   244458       2       4   144085      10   388543    215160
```

The cost has four items. First is the first build: the multi-stage layout produces 6 layers and
244,458 bytes, the single-stage one 4 layers and 215,142 bytes — a difference of 29,316 bytes,
exactly the size of the second stage's own base plus the carried bundle. Second is the source
change: the single-stage rebuilds 3 layers and 121,300 bytes, while the multi-stage one produces 4
layers and 144,085 bytes, because when the bundle changes, the cross-stage copy invalidates too.
Third is the cache: 7 entries and 336,442 bytes against 10 entries and 388,543 bytes.

The fourth item is the least visible. In the multi-stage layout, 215,160 bytes are produced on
every build and enter no image at all. The intermediate stage's layers are not shipped, but they
are produced; they sit on the disk of the machine doing the build, take up room in the cache, and
get reused on the next build. **The image got smaller, the store did not.** This is how the
isolation budget closes its account in this lesson: 185,826 bytes were pulled out of the runtime
environment, and in exchange, a persistent 215,160-byte stack appeared in the build environment,
along with about 18.8 percent more write work per build.

How do these four items turn into a decision? Two numbers are set side by side. The extra cost is
paid **per build** and is 29,316 bytes. The gain is paid **per copy of the image** and is 185,826
bytes: that many bytes are missing every time the image is written to the store, pulled to a
machine, and sits on disk. The ratio is 6.3 — even a single image produced by a single build more
than covers the extra cost. Considering the fictional measurement network's four services, the
difference multiplies, because the extra cost is paid once per build, while the gain reappears on
every store and every pull. The single-stage layout is defensible where the image is never stored
and is produced and run on a single machine; outside that, the measurement points one way.

What the stage boundary is **not** must also be written down. The `copy builder:/work/dist` line
is a copying boundary, not a verification boundary. The final stage takes the bundle the previous
stage produced exactly as it is; it does not ask what is inside it. If the bundler had not left
test files out, they would have entered the final image too, and the measurement would not have
caught it — because the `needed` definition is written by hand in this measurement. The image
itself does not know what is needed; the person who wrote the carried path guarantees it was
chosen correctly. This is the here-form of the shortfall measured in the previous lesson: an
instruction records not what is needed but what was copied.

## Summary

- Multi-stage building separates files needed at build time from files needed at runtime: the
  build happens in its own stage, and only the produced files travel to the final image.
- Built two ways, the same application's final image dropped from 215,142 bytes to 29,316 bytes
  (7.3x), file count from 14 to 3, layer count from 4 to 2.
- The single-stage image's 11 files and 185,826 bytes never ran at runtime at all — 86.4 percent
  of the image. Three build tools, four source files, two dev dependencies, and two runtime
  dependencies that had already entered the bundle.
- The cost has four items: 29,316 extra bytes produced on the first build, 4 layers instead of 3
  and 144,085 instead of 121,300 bytes on a source change, 10 cache entries instead of 7, and
  215,160 bytes produced on every build that enter no image. The image got smaller; the store on
  the building machine did not.
- The gain comes not from opening a second stage but from the narrowness of the path carried
  across: when the whole `/work` was carried instead of `/work/dist`, the final image grew to
  35,202 bytes and 7 files, and 4 unneeded files and 5,886 bytes came back.
- The stage boundary is a copying boundary, not a verification boundary. The final stage takes
  what the previous stage produced exactly as it is; the definition of what is needed at runtime
  lives outside the instruction, in the hands of whoever wrote it.

## Next Step

The final image has three files left, and two of them come from the base: the runtime base is
6,540 bytes, the bundle is 22,776 bytes. After everything the build produces has been measured and
shrunk, what remains is the image's **unproduced** portion — the base placed beneath it. Throughout
this lesson the base was a constant; which files the runtime base carries, which shell and which
libraries it contains, was never asked. Yet the base makes up about a fifth of the final image, and
its content is not something the writer produced — it is something taken from outside. The next
lesson measures this choice: how many bytes and how many files separate a small base from a broad
one, what does the small base leave out, and under what circumstances is that shortfall paid for?
