✦ For everyone, free.

Practical knowledge for real and everyday life

Home

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:

ImageWhat it containsTypical size
node:20-alpine baseNode.js runtime135MB
Single-stage (all deps + src)Everything450MB+
Multi-stage runtimeRuntime + prod deps + dist165MB
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:

StageSize
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.