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:


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:

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

Sources & Related Articles

External references consulted:

Related knowledge-base articles:

Change Log

2026-08-26

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse