Docker & Containers · Jan 2026 · 14 min read

Multi-Stage Builds: What Actually Gets Thrown Away

The same app, the same dependencies, built two ways — a naive single-stage Dockerfile and a real multi-stage one — measured byte-for-byte to see exactly what a second FROM line throws away.

The Claim Everyone Makes, Measured for Once

Every Docker tutorial says the same thing: multi-stage builds make your image smaller. Almost none of them show you the actual number. "Smaller" could mean 5% or 95% — those are completely different engineering decisions, and a claim you can't measure isn't one you should be repeating in production planning.

This article builds the exact same small Express + TypeScript app two ways — once as a naive single-stage Dockerfile, once as a real multi-stage one — and measures both resulting images with az acr manifest list-metadata, not a guess. No local Docker daemon was used to build either image; both were built with az acr build, which matters for reproducing this yourself without installing anything beyond the Azure CLI.

Before Step 1, one term this walkthrough leans on:


Step 1 — The app: small on purpose

import express from "express";

const app = express();
const PORT = process.env.PORT || 3000;

app.get("/healthz", (req, res) => {
  res.json({ status: "ok" });
});

app.listen(PORT, () => {
  console.log(`listening on ${PORT}`);
});
"dependencies": { "express": "^4.21.2" },
"devDependencies": {
  "typescript": "^5.7.3",
  "@types/express": "^4.17.21",
  "@types/node": "^22.10.5"
}

Why this app is deliberately tiny: the point of this article is the difference between two builds of identical source code, not the app itself. A minimal app makes the size delta attributable entirely to build strategy — there's no large dependency tree muddying the comparison.


Step 2 — The naive single-stage Dockerfile

FROM node:22
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]

Why this is what most people actually ship first: it's the Dockerfile you get by copying a tutorial without thinking about it — one FROM, one dependency install (which pulls devDependencies too, since there's no --omit=dev), and the full node:22 base image (not -alpine) because that's what worked without debugging native-module issues. Nothing here is wrong exactly — it builds, it runs, /healthz responds. It's just carrying weight nobody asked for.

Why npm ci rather than npm install, even here: npm ci requires a committed package-lock.json, deletes node_modules before installing, and installs the exact versions the lockfile specifies — it fails loudly if the lockfile and package.json disagree, rather than silently resolving a different dependency tree than the one that was tested. npm install can legally install slightly different versions on two different machines or two different days if the lockfile has drifted. In a Dockerfile specifically, that difference is the gap between "this build is reproducible" and "this build worked when I last ran it."


Step 3 — The real multi-stage Dockerfile

# --- build stage ---
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# --- runtime stage ---
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]

Why three things changed, not just one: it would be easy to credit all the savings to "multi-stage," but three separate decisions are stacked here. First, the build stage still uses full node:22 (TypeScript's native tooling doesn't need to be minimized, since it's discarded). Second, the runtime stage switches to node:22-alpine, a much smaller base image. Third, the runtime's npm ci --omit=dev never installs TypeScript or the @types/* packages at all — they were only ever needed to run tsc, which already happened in the build stage. COPY --from=build /app/dist ./dist is the only bridge between the two stages — the entire node_modules from the build stage, and the TypeScript compiler itself, never touch the final image.

The trade-off Alpine doesn't advertise: node:22-alpine is small specifically because it swaps glibc for musl libc, a different, smaller C standard library implementation. That's invisible for this app — Express and a plain HTTP server have no native dependencies — but it isn't always invisible. Packages with native addons (compiled C/C++ code, common in image processing, some database drivers, and crypto libraries) are frequently built and tested against glibc, and can fail to install or crash at runtime on musl unless the package specifically ships a musl-compatible build. Alpine's size advantage is real; it isn't a free swap for every Node.js application, and the honest move before adopting it in a real project is checking whether your specific dependency tree has any native modules first.


Step 4 — Building both, measuring both, no local Docker required

RG=docker-lab-rg
LOC=eastus
az group create --name $RG --location $LOC

ACR_NAME=dockerlabacr$RANDOM
az acr create --resource-group $RG --name $ACR_NAME --sku Basic --admin-enabled false

az acr build --registry $ACR_NAME --image multistage-lab:single -f Dockerfile.single .
az acr build --registry $ACR_NAME --image multistage-lab:multistage -f Dockerfile.multistage .

Both builds ran through az acr build, so neither required a Docker daemon on the machine running the command — the same reasoning as the ORIN and MYRIX articles in this series: it's what CI runners do anyway, so there's no separate "how CI builds it" to learn later.

The actual measurement:

az acr manifest list-metadata --registry $ACR_NAME --name multistage-lab \
  --query "[].{tags:tags,size:imageSize}"
[
  { "tags": ["single"], "size": 421816482 },
  { "tags": ["multistage"], "size": 59732577 }
]

421.8 MB vs. 59.7 MB — an 85.8% reduction, roughly 7.1x smaller, from identical application code. That's the number every "multi-stage builds make images smaller" claim should come with and almost never does.


Step 5 — Where the 362 MB actually went

Breaking down what each of the three decisions from Step 3 is responsible for is worth doing honestly rather than attributing the whole reduction to "multi-stage" as a buzzword:

None of these three would each individually produce an 85% reduction — it's the combination, and multi-stage builds are what make combining them possible without a second, separate manual cleanup step.


Closing Thoughts

"Multi-stage builds make your image smaller" is true and also nearly useless as engineering guidance until it's attached to a number. 421.8 MB and 59.7 MB are two real numbers, measured with az acr manifest list-metadata against an actual pushed image, not estimated from reading the Dockerfile. The next time this claim gets made in a design review or a code review comment, the useful response isn't agreement — it's "how much smaller, and can you show me the two manifest sizes?"

GitHub Repository: docker-multistage-lab — the real app, both Dockerfiles, and the exact commands used to measure them, MIT-licensed and runnable.

Reviewed against current Azure CLI (az acr build, az acr manifest list-metadata) as of September 2026.

Docker · Multi-Stage Builds · BuildKit · Container Images · Azure Container Registry