🏗️ Multi-stage Builds
Your app needs a whole toolbox to build — compilers, dev dependencies, bundlers — but almost none of it to run. Multi-stage builds let you use that heavy toolbox in one stage, then hand only the finished product to a tiny runtime stage. Same Dockerfile, a fraction of the size.
Week 11 · Day 2 (Tuesday: Creating Docker Images) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain the problem single-stage builds create — bloated images full of build-only tooling
- Write a multi-stage Dockerfile using multiple
FROMstatements and named stages - Move artifacts between stages with
COPY --from - Choose a heavy build base and a slim runtime base for the same app
- Build a specific stage with
--targetfor dev vs production - Structure stages so Docker's cache and parallel builder do the most work
Estimated Time: 65 minutes
Practice: Convert a single-stage Node build into a two-stage, slimmed image.
In This Lesson
The Single-Stage Problem
In the last lesson you built a clean, cached image. But look closely at a typical build for a TypeScript or React app and a problem appears: everything used to build the app is still sitting in the final image.
# Single-stage build — the final image carries all its build tooling
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install # installs devDependencies too: TypeScript, webpack, etc.
COPY . .
RUN npm run build # compiles into /app/dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
That image ships the full node:20 OS, every dev dependency, the TypeScript compiler, source maps, and your original source — none of which the running server needs. The result is often 800 MB to 1 GB when the app itself is a few megabytes.
⚠️ Why bloat is more than an annoyance
- Slower everything — bigger images push, pull, and deploy more slowly.
- Larger attack surface — every extra compiler and shell is another thing a scanner flags and an attacker can abuse.
- Wasted cost — registry storage and network transfer scale with size.
Before this feature existed, teams worked around it with two separate Dockerfiles or fragile cleanup scripts. Multi-stage builds solve it inside one file.
The Multi-stage Idea
The analogy is a construction site. To build a house you need scaffolding, cranes, cement mixers, and crews. But when the house is done, you do not leave the cranes parked in the living room — you take them away and keep only the finished house.
A multi-stage build works the same way. One build stage has all the heavy tooling and produces artifacts. A separate runtime stage starts fresh from a slim base and copies in only those artifacts. When the build finishes, Docker discards the build stage entirely — it never becomes part of the shipped image.
The dashed handoff is the whole trick: only the compiled artifacts cross from the build stage into the runtime stage. The compilers and dev dependencies stay behind and are thrown away.
Syntax: FROM ... AS and COPY --from
Two pieces of syntax make multi-stage builds work:
FROM <image> AS <name>— eachFROMstarts a new, independent stage;ASgives it a name you can reference.COPY --from=<name> <src> <dest>— copies files out of a named stage instead of the build context.
# ---- Stage 1: build (named "build") ----
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci # all deps, including devDependencies
COPY . .
RUN npm run build # produces /app/dist
# ---- Stage 2: runtime (the final image) ----
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev # ONLY production deps
COPY --from=build /app/dist ./dist # grab compiled output from stage 1
EXPOSE 3000
CMD ["node", "dist/server.js"]
The last FROM in the file defines the image you actually ship. Stage build did its job — compiling — and is discarded. The runtime image contains the Alpine OS, production dependencies, and your compiled dist, and nothing else.
A Real Node.js Example
Here is a complete, production-ready multi-stage Dockerfile for a TypeScript Express API. It combines everything: named stages, caching-friendly ordering, production-only runtime deps, and a non-root user.
# ================= Build stage =================
FROM node:20.11 AS build
WORKDIR /app
# Install ALL deps (TypeScript, types, etc.) reproducibly
COPY package*.json ./
RUN npm ci
# Compile TypeScript -> JavaScript in /app/dist
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# ============ Production deps stage ============
# A separate slim install of ONLY runtime dependencies
FROM node:20.11-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
# ================ Runtime stage ================
FROM node:20.11-alpine
WORKDIR /app
ENV NODE_ENV=production
# Copy production node_modules from the deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy compiled JS from the build stage
COPY --from=build /app/dist ./dist
COPY package.json ./
# Run as the built-in non-root user
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
✅ What each stage contributes
- build — heavy
node:20.11with all dev tooling; compiles TypeScript, then is thrown away. - deps — a clean Alpine install of production-only
node_modules, isolated so dev packages never leak in. - runtime — slim Alpine base + compiled
dist+ prod deps, running asnode.
Output
$ docker build -t api:2.0 .
$ docker images api
REPOSITORY TAG SIZE
api 2.0 135MB # single-stage equivalent was ~950MB
React Built, Nginx Served
Multi-stage builds shine brightest for front-end apps. A React build needs Node and hundreds of megabytes of tooling — but the output is just static HTML, CSS, and JavaScript. Those static files need only a web server. So: build with Node, serve with tiny Nginx.
# ---- Build the React app with Node ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # outputs static files to /app/build
# ---- Serve the static files with Nginx ----
FROM nginx:1.27-alpine
# Copy ONLY the built static assets — no Node, no node_modules
COPY --from=build /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
/build folder crosses into the Nginx image. All 400 MB of build tooling is discarded, leaving a ~25 MB final image.Named Targets: Dev vs Prod
You can build any named stage directly with the --target flag. This lets one Dockerfile serve both a development image (with dev tooling and hot reload) and a lean production image.
# Shared base with dependencies
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Development target: keep dev deps, run the dev server
FROM base AS development
ENV NODE_ENV=development
COPY . .
CMD ["npm", "run", "dev"]
# Build target: compile production artifacts
FROM base AS build
COPY . .
RUN npm run build
# Production target: slim runtime with only the build output
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
# Build just the development image
docker build --target development -t myapp:dev .
# Build the production image (the default final stage)
docker build --target production -t myapp:prod .
📖 Bonus: parallel builds with BuildKit
Modern Docker uses BuildKit by default. Independent stages that don't depend on each other are built in parallel, and unused stages are skipped entirely when you use --target. You get faster builds for free simply by structuring stages well.
Practice & Quiz
🏋️ Exercise 1: Split into two stages
Goal: Convert this single-stage TypeScript build into a two-stage build whose final image runs on Alpine and carries no dev dependencies.
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
💡 Hint
Name the first stage build. In a second FROM node:20-alpine stage, install with --omit=dev and pull the compiled output with COPY --from=build /app/dist ./dist.
✅ Solution
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
🏋️ Exercise 2: React to Nginx
Goal: Write a two-stage Dockerfile that builds a React app with Node and serves the static output with nginx:alpine.
✅ Solution
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Create React App outputs to build/; Vite outputs to dist/ — adjust the copy source to match your tool.
🎯 Quick Quiz
Question 1: What does COPY --from=build do?
Question 2: Which stage becomes the final shipped image?
Question 3: Why is the build stage's tooling absent from the final image?
Best Practices & Pitfalls
✅ Do
- Name stages meaningfully with
AS(build,deps,test) - Use a heavy base for building and a slim base (
alpine/slim) for the runtime stage - Copy only the artifacts you need with precise
COPY --frompaths - Install production-only dependencies (
npm ci --omit=dev) in the runtime stage - Keep caching-friendly ordering inside each stage (manifests before source)
❌ Don't
- Copy an entire stage's
/appwhen you only needdist - Reinstall dev dependencies in the runtime stage
- Forget the
USERinstruction just because the file got longer - Assume the build tools "carry over" — only
COPY --fromartifacts survive
⚠️ Match the output folder to your tool
COPY --from=build /app/dist fails silently-ish if your bundler writes to build/ instead of dist/. Create React App uses build/; Vite and tsc commonly use dist/. Check your tool's output path before writing the copy.
Summary
🎉 Key Takeaways
- Single-stage images ship their build tooling — often 800 MB+ of dead weight
- Multiple
FROM ... ASstages let you build heavy and run slim in one Dockerfile COPY --from=<stage>moves only the artifacts into the final image; the rest is discarded- Build with Node, serve static front-ends with tiny Nginx; run APIs on Alpine with prod-only deps
--targetbuilds a specific stage, so one file serves both dev and prod
📚 Additional Resources
🚀 What's Next?
Multi-stage builds cut the biggest chunk of image bloat. The next lesson, Image optimization, takes it further: choosing between alpine, slim, and distroless bases, combining layers, pruning caches, and measuring exactly where every megabyte goes.
🎉 From gigabytes to megabytes!
You now hold the single most effective trick for shrinking Docker images.