Multi-Stage Builds¶
Some images carry a lot of dead weight. They ship the tools used to build the app, even though those tools are useless once the app is just running. Multi-stage builds fix that, often shrinking an image from over a gigabyte to a few dozen megabytes. 📉
In this lesson your Snapshot app gains a small frontend, and it's the perfect example: a frontend needs a big toolbox to build, but only some static files and a tiny web server to run.
What we will do (in very simple steps)¶
- Create a tiny frontend
- Build it the naive way and see how huge the image is
- Rebuild it with a multi-stage Dockerfile
- Compare the two sizes
The idea 🧰¶
Imagine baking a cake. You need a messy kitchen full of bowls, mixers, and flour to make it. But to serve it, you just need the cake on a plate. You wouldn't hand someone the entire dirty kitchen.
A multi-stage build does exactly this. One stage is the messy kitchen where the app is built. A second, clean stage takes only the finished result and throws the kitchen away.
Step 1: Create the frontend¶
Give the frontend its own folder, separate from your API:
Create package.json:
{
"name": "snapshot-frontend",
"version": "1.0.0",
"scripts": {
"build": "vite build"
},
"devDependencies": {
"vite": "^5.0.0"
}
}
Create index.html:
<!doctype html>
<html>
<head>
<title>Snapshot</title>
</head>
<body>
<h1>Snapshot Gallery</h1>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Create a src folder with a file main.js inside it:
src/main.js:
Don't worry about the frontend code itself. The point of this lesson is the Dockerfile, not the JavaScript.
Step 2: Build it the naive way (and weigh it)¶
First, let's build it the obvious way, in a single stage. Create a file called Dockerfile:
Build it with a naive tag:
Now weigh it:
You'll see something startling, an image around 1.1 GB. 😳 It contains all of Node, every build tool, and the whole node_modules folder, none of which is needed just to serve a few static files.
Step 3: Rebuild it as a multi-stage build¶
Now replace the entire contents of your Dockerfile with this:
# Stage 1: the messy kitchen, where we build the app
FROM node:20 AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
# Stage 2: the clean plate, where we only serve the result
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
Two new pieces do all the work:
FROM node:20 AS buildnames the first stagebuild. This is the messy kitchen.FROM nginx:alpinestarts a brand new, tiny stage from scratch. Nothing from the first stage carries over automatically.COPY --from=build /app/dist /usr/share/nginx/htmlreaches back into thebuildstage and copies only the finished static files into the clean image.
Everything in the build stage, Node, npm, all the tools, is thrown away. Only the built files survive. Build it with a multi tag:
Step 4: Compare, and run the small one¶
Weigh both images side by side:
| Tag | Approx size |
|---|---|
naive |
~1.1 GB |
multi |
~50 MB |
Same app, roughly twenty times smaller. 🎯 That difference is pure build-time clutter that the multi-stage version left behind.
Now run the small one and see it works perfectly:
Visit http://localhost:8080 and you'll see your Snapshot Gallery page. Clean up when done:
Why smaller images matter¶
A leaner image is not just tidy, it's practical:
- Faster to pull and deploy, especially across a slow network
- Cheaper to store in a registry
- Safer, because fewer tools inside means fewer things that can be exploited
The same technique applies to any language that has a separate build step, including compiled apps and, as you'll often see in the wild, the exact frontend pattern you just used.
✅ Checkpoint¶
You've finished this lesson if:
- The
naiveimage built and was very large (around a gigabyte) - The
multiimage built and was tiny (tens of megabytes) docker images snapshot-frontendshows the dramatic difference- The
multiimage ran and served your page at port 8080
🩹 Common hiccups¶
npm installis slow: that's normal on the first build. Later builds reuse cached layers.- "vite: not found": the build stage didn't run
npm install. Check the Dockerfile matches exactly. - "/app/dist not found" during COPY: the build didn't produce a
distfolder. Make sureRUN npm run buildis in the build stage and the stage is namedAS build. - Page won't load: confirm you ran the
:multiimage and mapped-p 8080:80.
Next up: Optimize Image Size, where you'll shrink images even further with smaller base images, fewer layers, and a .dockerignore file.