Docker · lesson 7 of 9
Intermediate
3Also part of Docker
5- + Deploying FastAPI with Docker and Kubernetes: Images, Signals, and Probes from FastAPI
- + Dockerizing Spring Boot Applications from Java
- + Bash Process Management from Bash
- + CIS Docker Benchmark Checks with docker-bench-security from Cloud Security
- + Signing Container Images with Cosign: Verify the Digest, Not the Tag from DevSecOps (AppSec in the pipeline)
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
- List
node_modulesinside the finisheddemo:multiproduction image. Confirmlodash(a dependency) is there andrimraf(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.
- Confirm
demo:multiactually runs as thenodeuser, not root, as theUSER nodeline claims.
Solution
docker run --rm demo:multi whoami
$ docker run --rm demo:multi whoami
node
- Build just the
builderstage ofDockerfile.multiwith--target builderand compare its size to the finisheddemo:multiimage.
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.
- Change the Go program’s output string, rebuild
godemo:multi, and confirm the new message comes through thescratchimage.
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.”
- 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.