Happy Dussehra! May good always win. Happy Diwali! Wishing you a bright year ahead. Merry Christmas! Warm wishes from Vizag. Happy New Year! Here’s to a great year ahead.

Archive · Docker

Multi-Stage Builds and BuildKit

Build small, safer images by separating the build toolchain from the runtime, and speed up builds with BuildKit caching, secrets and parallel stages.

4 min read

  • Docker Engine 19.03.13
  • BuildKit
  • golang:1.15
  • alpine:3.12

A naive Dockerfile ships the compiler, the package manager and the source code into production, which makes images large and gives an attacker more tools. Multi-stage builds compile in one stage and copy only the result into a minimal final stage. BuildKit, Docker’s newer build engine, makes the process faster and adds secret handling and cache mounts.

Versions in use on this date. Docker Engine 19.03.13 (September 2020). BuildKit is included in 19.03 and enabled with DOCKER_BUILDKIT=1; it is not yet the default. The advanced RUN --mount features use the docker/dockerfile:experimental frontend. Examples use Go 1.15 (August 2020) and Alpine 3.12.

Stages and what ships

flowchart TB
  SRC[Source code and go.mod] --> B
  subgraph B[Stage 1 build image golang 1.15 about 300 MB]
    DEP[go mod download<br/>cached layer] --> COM[go build static binary]
  end
  COM -->|COPY --from=build binary only| R
  subgraph R[Stage 2 runtime image alpine 3.12 about 6 MB]
    BIN[App server binary] --> USR[Non-root user]
  end
  R --> IMG[Final image around 12 MB]
  B -.->|discarded| DROP[Compiler and sources not shipped]

A single-stage image (the problem)

FROM golang:1.15
WORKDIR /src
COPY . .
RUN go build -o /server ./cmd/server
CMD ["/server"]

This produces an image of several hundred MB with the entire Go toolchain and your source inside.

The multi-stage version

# syntax=docker/dockerfile:experimental
FROM golang:1.15-alpine 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 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server

FROM alpine:3.12
RUN apk add --no-cache ca-certificates tzdata \
 && addgroup -S app && adduser -S -G app app
COPY --from=build /out/server /usr/local/bin/server
USER app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/server"]
export DOCKER_BUILDKIT=1
docker build -t myorg/server:1.0.0 .
docker images myorg/server          # compare the sizes
docker build --target build -t server-build .       # build only the first stage, for debugging

Key points:

  • CGO_ENABLED=0 builds a static binary that does not need glibc, so it runs on Alpine or even scratch.
  • COPY --from=build takes just the binary. The compiler, module cache and source stay behind in an earlier stage.
  • Copy go.mod and go.sum first so the dependency download is cached until they change.
  • A non-root USER and installed CA certificates (for outbound HTTPS) are the minimum for a runtime image.

For languages like Node.js or Java, the same pattern applies: build in a fat image (node:12, maven), run in a slim one (node:12-alpine, a JRE image).

What BuildKit adds

Feature Use
Parallel stages Independent stages build at the same time; unused stages are skipped
RUN --mount=type=cache Persistent package or compiler caches between builds (needs the experimental syntax line)
RUN --mount=type=secret Use a secret at build time without it landing in a layer
--ssh Forward your SSH agent to clone private repos during the build
Better output --progress=plain shows full logs

Using a secret safely:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

Unlike ARG or ENV, the secret does not appear in docker history or in the final image.

Choosing the runtime base

Base Size Notes
scratch 0 Just your static binary; no shell, no CA certificates unless you copy them
alpine ~6 MB Shell and package manager; musl libc
gcr.io/distroless/static ~2 MB No shell, includes CA certs; harder to debug
debian:buster-slim ~70 MB glibc compatibility

Smaller images pull faster, start faster and contain fewer vulnerabilities to scan. Debug by running a separate debug image or the build stage, not by shipping a shell in production.

Habits

  1. Use .dockerignore to keep .git, tests and local build artefacts out of the context.
  2. Pin base tags (golang:1.15.2-alpine) and rebuild regularly for patches.
  3. Run the container as non-root, with a read-only root filesystem where possible (docker run --read-only).
  4. Scan the final image (see the Trivy post) and keep the build stage out of registries.
  5. In CI, use --cache-from or a registry cache to reuse layers between runs.