Skip to main content
Docker · lesson 7 of 9
Docker advanced Lesson 7 of 9

Also part of: Go FastAPI DevSecOps (AppSec in the pipeline)

Multi-Stage Builds

Use multi-stage Dockerfiles to produce small, secure production images by separating build and runtime environments.

By SkillByExample · Updated

The Problem with Single-Stage Builds

A naive Dockerfile includes everything needed to build the app — compiler, dev dependencies, and source — in the image that ships:

# Dockerfile.single
FROM node:18
WORKDIR /app
COPY . .
RUN npm install          # includes devDependencies
RUN npm run build
CMD ["node", "dist/index.js"]
docker build -f Dockerfile.single -t demo:single .
docker images demo:single --format "{{.Size}}"
$ docker build -f Dockerfile.single -t demo:single .
[+] Building 5.4s
$ docker images demo:single --format "{{.Size}}"
1.1GB

Multi-Stage Dockerfile

Same app, split into a builder stage that compiles and a production stage that only copies the result:

# Dockerfile.multi
# ── Stage 1: Install all dependencies and build ──────────────────
FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm install                     # all deps including dev
COPY . .
RUN npm run build                   # produces /app/dist

# ── Stage 2: Production image ────────────────────────────────────
FROM node:18-alpine AS production

WORKDIR /app

# Copy only production dependency manifest
COPY package*.json ./
RUN npm install --omit=dev          # no devDependencies

# Copy compiled output from builder stage
COPY --from=builder /app/dist ./dist

# Run as non-root
USER node

CMD ["node", "dist/index.js"]
docker build -f Dockerfile.multi -t demo:multi .
docker images demo --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
docker run --rm demo:multi
$ docker build -f Dockerfile.multi -t demo:multi .
[+] Building 0.8s
$ docker images demo --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
REPOSITORY:TAG   SIZE
demo:multi       132MB
demo:single      1.1GB
$ docker run --rm demo:multi
[ [ 1, 2 ], [ 3, 4 ], [ 5, 6 ] ]

Same program, same output, 8x smaller. docker run --rm demo:multi ls node_modules would show lodash (a production dependency) but not rimraf (a devDependency) — npm install --omit=dev in the production stage never installed it, and COPY --from=builder only copied dist/, not node_modules or src/.

Targeting a Stage

--target builds up to a named stage and stops — useful for running tests against the stage that still has dev dependencies and source, without shipping either:

# Build the production stage (default — last FROM)
docker build -f Dockerfile.multi -t myapp:prod .

# Build only the builder stage (useful for running tests in CI)
docker build --target builder -f Dockerfile.multi -t myapp:test .

# Run tests against the builder stage
docker run --rm myapp:test npm test
$ docker build -f Dockerfile.multi -t myapp:prod .
[+] Building 0.0s (12/12) FINISHED
$ docker build --target builder -f Dockerfile.multi -t myapp:test .
[+] Building 0.8s (9/9) FINISHED
$ docker run --rm myapp:test npm test

> multistage-demo@1.0.0 test
> node -e "console.log('All tests passed')"

All tests passed

Go: From 287MB to 2.3MB

A statically-linked Go binary needs nothing from the OS at runtime — so the final stage can be scratch, an image with literally nothing in it:

# Stage 1: compile
FROM golang:1.27-alpine AS builder

WORKDIR /app
COPY go.mod ./
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .

# Stage 2: scratch image — literally empty, just the binary
FROM scratch AS production

COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
docker build -f Dockerfile.single -t godemo:single .     # FROM golang:1.27-alpine, no second stage
docker build -f Dockerfile.multi -t godemo:multi .
docker images godemo --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
docker run --rm godemo:multi
$ docker images godemo --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
REPOSITORY:TAG   SIZE
godemo:multi     2.34MB
godemo:single    287MB
$ docker run --rm godemo:multi
Hello from a scratch image

The scratch image runs the binary with no shell, no package manager, and no C library — CGO_ENABLED=0 is what makes that possible, by statically linking everything the binary needs instead of dynamically linking against glibc. A binary that calls out over TLS would still need COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/, since scratch has no CA bundle of its own.

Java Spring Boot

# Stage 1: build with Maven
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN ./mvnw package -DskipTests

# Stage 2: extract layers for better caching (Spring Boot 2.3+)
FROM eclipse-temurin:21-jdk-alpine AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# Stage 3: lean runtime
FROM eclipse-temurin:21-jre-alpine AS production
WORKDIR /app
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Reusing Stages as a Base

FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./

FROM base AS dev-deps
RUN npm ci

FROM base AS prod-deps
RUN npm ci --only=production

FROM dev-deps AS builder
COPY . .
RUN npm run build

FROM node:18-alpine AS production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]

Build Arguments Across Stages

ARG NODE_VERSION=18

FROM node:${NODE_VERSION}-alpine AS builder
# ARG must be redeclared in each stage to be accessible there
ARG NODE_VERSION
RUN echo "Building with Node ${NODE_VERSION}"
RUN node --version
docker build --build-arg NODE_VERSION=20 -t myapp:node20 . --no-cache
$ docker build --build-arg NODE_VERSION=20 -t myapp:node20 . --no-cache
[2/3] RUN echo "Building with Node 20"
Building with Node 20
[3/3] RUN node --version
v20.20.2

Leave --build-arg off and the Dockerfile’s own ARG NODE_VERSION=18 default applies — the override only takes effect when it’s passed explicitly at build time.

CI/CD Integration

The same --target split that supports local testing also drives a CI pipeline: build the builder stage, run tests against it, and only build+push production if they pass. Pushing needs somewhere to push to — this demo spins up a throwaway local registry:

docker run -d -p 5000:5000 --name local-registry registry:2

# In CI: build the test stage, run tests, then build and push production
docker build --target builder -f Dockerfile.multi -t myapp:test .
docker run --rm myapp:test npm test

docker build --target production -f Dockerfile.multi -t localhost:5000/myapp:a1b2c3d .
docker push localhost:5000/myapp:a1b2c3d
$ docker run --rm myapp:test npm test

> multistage-demo@1.0.0 test
> node -e "console.log('All tests passed')"

All tests passed
$ docker push localhost:5000/myapp:a1b2c3d
The push refers to repository [localhost:5000/myapp]
a1b2c3d: digest: sha256:38037f93ae0082105fcae5f345c3f1c73a69a514f5bc9ca3e011a5021e524cb0 size: 1989

A real pipeline pushes to a managed registry (Docker Hub, ECR, GHCR) instead of localhost:5000, and ${GIT_SHA} usually comes from the CI environment rather than being typed by hand — the mechanics are identical either way: build the test stage first, fail the pipeline if npm test fails, and only push the image nothing failed to build.

Practice

  1. List node_modules inside the finished demo:multi production image. Confirm lodash (a dependency) is there and rimraf (a devDependency) is not.
Solution
docker run --rm demo:multi ls node_modules
$ docker run --rm demo:multi ls node_modules
@isaacs
@pkgjs
lodash

rimraf doesn’t appear because the production stage ran npm install --omit=dev against its own package.json copy — it never had the chance to install. @isaacs/@pkgjs are lodash’s own transitive dependencies, not anything the Dockerfile asked for directly.

  1. Confirm demo:multi actually runs as the node user, not root, as the USER node line claims.
Solution
docker run --rm demo:multi whoami
$ docker run --rm demo:multi whoami
node
  1. Build just the builder stage of Dockerfile.multi with --target builder and compare its size to the finished demo:multi image.
Solution
docker build --target builder -f Dockerfile.multi -t demo:builder .
docker images demo --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
$ docker images demo --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
REPOSITORY:TAG   SIZE
demo:builder     137MB
demo:multi       132MB

For an app this small, the difference is only rimraf and a copy of src/ — a handful of MB. The gap widens with real devDependencies: a project using webpack or typescript as a devDependency would show a much bigger drop between builder and production.

  1. Change the Go program’s output string, rebuild godemo:multi, and confirm the new message comes through the scratch image.
Solution
# edit main.go: fmt.Println("Hello from a rebuilt image")
docker build -f Dockerfile.multi -t godemo:multi .
docker run --rm godemo:multi
$ docker run --rm godemo:multi
Hello from a rebuilt image

Editing main.go only invalidates the builder stage’s go build layer — scratch has nothing to invalidate, so the rebuild is just “recompile, then copy the new binary over.”

  1. The local registry from the CI/CD example is still running. Remove the local copy of localhost:5000/myapp:a1b2c3d, then pull it back to confirm the push actually landed.
Solution
docker rmi localhost:5000/myapp:a1b2c3d
docker pull localhost:5000/myapp:a1b2c3d
$ docker pull localhost:5000/myapp:a1b2c3d
a1b2c3d: Pulling from myapp
Digest: sha256:38037f93ae0082105fcae5f345c3f1c73a69a514f5bc9ca3e011a5021e524cb0
Status: Downloaded newer image for localhost:5000/myapp:a1b2c3d
localhost:5000/myapp:a1b2c3d

A successful pull of exactly the digest that was pushed is the real proof — docker push reporting success only means the client finished sending layers, not that the registry stored them correctly.

Frequently Asked Questions

Why are multi-stage builds important?
Build tools (compilers, test runners, npm, Maven) can add hundreds of MBs to an image. Multi-stage builds let you compile in a full build environment and copy only the final artifact into a minimal runtime image, dramatically reducing image size and attack surface.
Can I have more than two stages?
Yes — you can have as many stages as needed. Common patterns: deps → test → build → production. Each stage can be targeted individually with --target.
How much smaller are multi-stage images in practice?
Typically 5–10x smaller, and more for compiled languages. A small Node.js app built both ways on this page goes from 1.1GB (full node image with devDependencies) to 132MB (node:alpine with only production deps) — about 8x. A Go binary goes from 287MB (golang:1.27-alpine, full toolchain) to 2.3MB on scratch — the toolchain is the entire difference.