Multi-Stage Builds - Efficient Images in CI Pipelines
Status: Active Last Updated: 2026-08-26 Category: CI/CD - Container Build Techniques Prerequisites: dockerfile-guide, woodpecker-first-pipeline Time: 2 hours Tags: docker, buildkit, multi-stage, ci, woodpecker, caching
Summary
Multi-stage builds separate compilation from runtime: heavy toolchains live in builder stages, only artifacts ship in the final image. This article covers writing multi-stage Dockerfiles for CI, enabling BuildKit through Woodpecker, and exploiting layer caching so pipelines stay fast.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ Write multi-stage Dockerfiles that shrink final images by 5โ10x
- โ Enable BuildKit and its cache mounts inside Woodpecker steps
- โ
Use
--targetto reuse one Dockerfile for dev/test/prod images - โ Configure registry-backed layer caching in the docker-buildx plugin
- โ
Verify image contents with
dive-style inspection
Context / Why This Matters
A single-stage Go or Node build produces images carrying compilers, package caches, and test tooling into production โ bigger pulls on every deploy host, larger attack surface, slower cold starts. Multi-stage plus BuildKit caching gives you small images without slow rebuilds, which is what makes per-commit CI practical. Pairs naturally with container-best-practices.
Implementation / Core Content
Anatomy of a Multi-Stage Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot
ENTRYPOINT ["/app"]
Key mechanics:
- Only the last stage becomes the tagged image;
buildis discarded. --mount=type=cachekeeps module/build caches outside the layers, so dependency downloads don't invalidate on every source change.distroless/alpine finals cut image size and CVE count dramatically.- Copying
go.modbefore source means dependency layers cache independently of code edits.
Same pattern for Node:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/main.js"]
BuildKit in Woodpecker
BuildKit is required for cache mounts and modern syntax. With the official buildx plugin it's on by default; with plain docker build commands ensure the agent's daemon has DOCKER_BUILDKIT=1.
steps:
build:
image: woodpeckers/plugin-docker-buildx
settings:
repo: registry.internal/acme-corp/api-service
registry: registry.internal
username:
from_secret: registry_user
password:
from_secret: registry_password
tags: "${CI_COMMIT_TAG:-latest}"
# Registry-backed layer cache: pull prior layers, push new ones
cache_from: "type=registry,ref=registry.internal/acme-corp/api-service:buildcache"
cache_to: "type=registry,ref=registry.internal/acme-corp/api-service:buildcache,mode=max"
mode=max caches intermediate stages too, not just the final image โ important when your builder stage is expensive.
Reusing One Dockerfile Across Targets
steps:
test-image:
image: woodpeckers/plugin-docker-buildx
settings:
dockerfile: Dockerfile
target: build # includes test tooling
dry_run: true
commands: []
prod-image:
when:
event: tag
image: woodpeckers/plugin-docker-buildx
settings:
dockerfile: Dockerfile
target: "" # default = final stage
tags: "${CI_COMMIT_TAG}"
Verifying What Shipped
# Size + layer breakdown
docker history registry.internal/acme-corp/api-service:v1.8.2
# Find which stage a file came from / spot leaked secrets
docker buildx build --platform linux/amd64 -t audit --load .
docker run --rm --entrypoint sh audit -c 'ls -la /' # distroless has no shell โ good sign
# Quick size comparison during development
docker build --target build -t api:build .
docker images | grep api
Expect a healthy result: builder ~1GB+, final <100MB for Go, <250MB for Node.
Practical Examples
Example 1: Full CI pipeline with cached multi-stage build
when:
event: [push, tag]
variables:
image: &image registry.internal/acme-corp/api-service
steps:
build-push:
image: woodpeckers/plugin-docker-buildx
settings:
repo: *image
registry: registry.internal
username: { from_secret: registry_user }
password: { from_secret: registry_password }
tags: |
${CI_COMMIT_SHA}
${CI_COMMIT_TAG:-edge}
cache_from: type=registry,ref=${CI_REPO}:buildcache
cache_to: type=registry,ref=${CI_REPO}:buildcache,mode=max
Example 2: Measure cache effectiveness
# Touch only application code โ dependency layer must NOT rebuild
touch cmd/app/main.go
time woodpecker-cli exec .woodpecker.yaml
# Expect: 'go mod download' absent/instant, total well under a minute
Common Pitfalls & Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
--mount=type=cache ignored / error |
Legacy builder active | Use buildx plugin or set DOCKER_BUILDKIT=1 on the agent |
| Cache never hits in CI | Fresh agent each run, no cache backend | Use registry (cache_from/cache_to) or persistent agent volumes (caching-strategies) |
| Final image huge | Accidentally copied node_modules/toolchain into final stage | Audit with docker history; copy artifacts only via --from |
| Secrets leak into image | Secret used in a RUN of the final stage, or COPY'd file kept | Use --mount=type=secret; keep secret-consuming work in builder stages |
| Build works locally, fails in CI | Platform mismatch (arm64 vs amd64) | Pin platform: linux/amd64 in plugin settings or use multi-platform builds |
npm ci runs on every commit |
Source copied before dependency install | Order: manifest files โ install โ then copy source |
| distroless image can't be debugged | No shell by design | Keep debug variant target: --target=debug with busybox |
Next Steps / Ops Actions
- Push/pull strategy details and registry choice: container-registry-integration
- Cache everything else too: caching-strategies
- Scan the resulting images: security-scanning
Sources & Related Articles
External references consulted:
- https://docs.docker.com/build/building/multi-stage/
- https://docs.docker.com/build/cache/optimize/#use-cache-mounts
- https://woodpecker-ci.org/plugins/docker-buildx
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation covering multi-stage Dockerfiles, BuildKit cache mounts, registry layer caching, and verification workflow.