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:
-
Build stage vs. runtime stage — a multi-stage Dockerfile has more than one
FROMline. Everything before the lastFROMis a build stage: it can install compilers, type-checkers, and dev tooling, and none of that has to end up in the final image. The lastFROMstarts the runtime stage, and only what you explicitlyCOPY --from=an earlier stage survives into it. Docker discards every earlier stage's filesystem once the final stage is built — that's the mechanism this article measures.
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 ./
RUN npm install
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 npm 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.
Step 3 — The real multi-stage Dockerfile
# --- build stage ---
FROM node:22 AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# --- runtime stage ---
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json ./
RUN npm install --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 install --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.
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": 426416099 },
{ "tags": ["multistage"], "size": 64335148 }
]
426.4 MB vs. 64.3 MB — an 85% reduction, roughly 6.6x 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:
-
Base image switch (
node:22→node:22-alpine) is the single largest contributor — the full Debian-basednode:22image carries a general-purpose Linux userland (compilers, libraries, package-manager metadata) that a Node.js process never touches at runtime. Alpine's musl-libc-based image is built to be minimal by default. -
Excluding devDependencies (
npm install --omit=dev) removes TypeScript and its type-definition packages from the shipped image — packages that exist purely to producedist/server.js, with no runtime purpose once that file exists. -
Discarding the build stage's
node_modulesentirely means even the production dependencies get reinstalled fresh in the runtime stage viaRUN npm install --omit=dev, rather than being copied over from the build stage — this avoids carrying forward any build-stage artifacts (native module build caches, npm's own cache directory) thatnpm installleaves behind.
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. 426.4 MB and 64.3 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
Originally published on my portfolio.
Top comments (0)