20.2.2.3 Multi Stage Learning Exercise
A focused guide to Multi Stage Learning Exercise, connecting core concepts with practical Docker and container operations.
Multi-stage builds let a single Dockerfile define multiple sequential build environments and copy only selected artifacts between them. The key benefit is that build tools, compilers, test runners, and intermediate files that are needed to produce the application binary or bundle never appear in the final image. The final image contains only the runtime and the built artifact — nothing that was used to produce it.
The Core Concept
A Dockerfile with multiple stages uses FROM more than once. Each FROM begins a new stage with its own isolated filesystem. Stages are numbered from zero (first) to N, and can be named with AS:
FROM node:20-alpine AS builder
# ... build steps ...
FROM node:20-alpine AS runtime
# ... copy from builder and define CMD ...
The runtime stage starts from a clean node:20-alpine and has no access to the builder stage's filesystem except through explicit COPY --from=builder instructions.
Exercise: Building a TypeScript API
This exercise builds a TypeScript Node.js API with a multi-stage Dockerfile. The goal is to produce a final image that contains only compiled JavaScript and production dependencies — no TypeScript compiler, no type definitions, no source .ts files.
Project Structure
my-api/
Dockerfile
.dockerignore
package.json
package-lock.json
tsconfig.json
src/
server.ts
routes/
api.ts
package.json includes TypeScript as a dev dependency and defines a build script:
{
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.18.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/express": "^4.17.0"
}
}
tsconfig.json outputs to dist/:
{
"compilerOptions": {
"outDir": "./dist",
"target": "ES2022",
"module": "commonjs"
},
"include": ["src/**/*"]
}
Stage 1: The Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Install all dependencies including devDependencies
COPY package.json package-lock.json ./
RUN npm ci
# Copy source and compile
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
After this stage, /app/dist/ contains compiled JavaScript and /app/node_modules contains all packages including TypeScript and type definitions.
Stage 2: The Runtime
FROM node:20-alpine AS runtime
WORKDIR /app
# Install only production dependencies
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Copy compiled output from builder
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
The runtime stage installs only production dependencies (no TypeScript, no @types/*) and copies the dist/ directory from the builder stage. The final image never contains .ts source files, the TypeScript compiler, or dev dependencies.
Building and Measuring the Result
docker build -t my-api:latest .
Check the resulting image size:
docker images my-api
Compare sizes to understand what multi-stage builds save:
| Image | What it contains | Typical size |
|---|---|---|
node:20-alpine base | Node.js runtime | 135MB |
| Single-stage (all deps + src) | Everything | 450MB+ |
| Multi-stage runtime | Runtime + prod deps + dist | 165MB |
The COPY --from Instruction
COPY --from=builder copies files from the named stage's filesystem:
COPY --from=builder /app/dist ./dist
The source path (/app/dist) is an absolute path within the builder stage's container filesystem. The destination (./dist) is relative to the WORKDIR of the current stage.
Multiple COPY --from instructions can reference different stages in the same final image:
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/generated-config.json ./config.json
Targeting a Specific Stage
To build only up to a named stage (useful for development or CI):
docker build --target builder -t my-api:builder .
This builds only the builder stage and stops. The resulting image includes the TypeScript compiler and source code, making it useful for debugging compilation errors or running tests in CI without producing the final production image.
Running Tests in a Dedicated Stage
Add a test stage between builder and runtime:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM builder AS test
RUN npm test
FROM node:20-alpine AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
The test stage inherits from builder (already has all dependencies and compiled output) and runs the test suite. If tests fail, docker build fails and the runtime stage is never reached. This enforces that the production image is never built from a codebase with failing tests.
In CI, build the test stage explicitly to run tests, then build the full image for deployment:
docker build --target test -t my-api:test .
docker build -t my-api:latest .
Multi-Stage for Go
Go is an especially clean fit for multi-stage builds because Go produces statically linked binaries with no runtime dependencies:
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server .
FROM scratch
COPY --from=builder /server /server
ENTRYPOINT ["/server"]
The final image is built FROM scratch — the empty image — and contains only the compiled binary. The Go compiler, standard library source, and downloaded modules are entirely absent from the production image.
Typical sizes:
| Stage | Size |
|---|---|
golang:1.22-alpine builder | ~240MB |
Final scratch image | ~8MB |
This 97% size reduction is achievable because Go compiles everything into a single static binary.
Inspecting the Final Image
After building, confirm that no build tools ended up in the final image:
docker run --rm my-api:latest which tsc
Expected result: which: tsc: not found — the TypeScript compiler is not present.
docker run --rm my-api:latest ls node_modules | grep typescript
Expected result: no output — TypeScript is not in production node_modules.
docker history my-api:latest
The layer history shows only the runtime stage's steps — no trace of the builder stage's layers, which are discarded after the final image is assembled.