BuildKit builds: stages, cache and mounts

Multi-stage builds, parallel stages, cache, bind, secret and SSH mounts.

Intermediate15 min · lesson 4 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (8 files, 1 KB): multistage.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/multistage.tar.gz && tar -xzf multistage.tar.gz, which creates ~/lab/multistage/. SHA-256: 54b3be30a2181804307eabd9a00f74aab57f9c605c318ca559b85ab8b43005c3

A Go service ships as a 493MB image. The binary inside it is 5MB. The rest is the Go toolchain, Alpine's package manager and the source tree. They're all copied in because the Dockerfile compiles and runs in the same image. Every pull you make moves those bytes, and every scanner you run reports their CVEs. Every code change recompiles the whole standard library, because one COPY . . invalidated the cache. You'll fix all three with BuildKit, the builder behind docker build. The tools are multi-stage builds, stages that run in parallel, and RUN --mount for caches, sources, secrets and SSH agents.

Use the main lab VM, secopslog-docker. The Go module, the Dockerfiles and .dockerignore are in the lesson files download; unpack them into ~/lab/multistage. No host settings change.

BuildKit is already the builder

Docker Engine 29 builds with BuildKit by default. docker build is handled by the Buildx plugin, which sends the build to a builder. On a fresh install that's the default builder, BuildKit running inside dockerd:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker buildx ls
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS default* docker \_ default \_ default running v0.34.0 linux/arm64

docker in the DRIVER column means the builder lives in the daemon and writes straight into the local image store. BuildKit v0.34.0 is the version embedded in Engine 29.9.2. "Buildx builders, outputs, cache and Bake" adds builders that run in their own containers. Everything here uses the default one. The legacy builder still exists behind DOCKER_BUILDKIT=0. It's deprecated, and it doesn't understand any of the RUN --mount options below.

Stages and targets

Each FROM starts a stage. A stage can start from an image or from an earlier stage, and COPY --from=<stage> copies files out of another stage. Only the stage you build (by default the last one) becomes the image. The Dockerfile you'll use has five:

Dockerfile
FROM golang:1.27-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c AS base
WORKDIR /src
ENV CGO_ENABLED=0
FROM base AS build
RUN --mount=type=bind,target=. \
--mount=type=cache,id=lab-go-build,target=/root/.cache/go-build \
go build -trimpath -ldflags="-s -w" -o /out/lab-app .
FROM base AS test
RUN --mount=type=bind,target=. \
--mount=type=cache,id=lab-go-build,target=/root/.cache/go-build \
go test ./... && touch /tmp/test.ok
FROM base AS vet
RUN --mount=type=bind,target=. \
--mount=type=cache,id=lab-go-build,target=/root/.cache/go-build \
go vet ./... && touch /tmp/vet.ok
FROM scratch AS checks
COPY --from=test /tmp/test.ok /
COPY --from=vet /tmp/vet.ok /
FROM scratch AS runtime
COPY --from=build /out/lab-app /lab-app
USER 65532:65532
ENTRYPOINT ["/lab-app"]

base holds what you need for every Go step. build, test and vet each start from base and don't depend on each other. checks collects marker files from test and vet. runtime starts from scratch, an empty filesystem, and receives one file from build. The golang image is pinned by tag and digest as in "Tags, digests and promotion". The final stage runs as the numeric user 65532, because scratch has no /etc/passwd to look a name up in ("Run as non-root" in Advanced container security covers numeric IDs). The next sections explain the RUN --mount lines. Build the default target:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker build --progress=plain -t lab-app:1 .
#0 building with "default" instance using docker driver ... #4 [internal] load build context #4 transferring context: 2.01kB done #4 DONE 0.0s #5 [base 1/2] FROM docker.io/library/golang:1.27-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c #5 resolve docker.io/library/golang:1.27-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c 0.0s done #5 DONE 0.1s ... #6 [base 2/2] WORKDIR /src #6 DONE 0.3s #7 [build 1/1] RUN --mount=type=bind,target=. --mount=type=cache,id=lab-go-build,target=/root/.cache/go-build go build -trimpath -ldflags="-s -w" -o /out/lab-app . #7 DONE 6.5s #8 [runtime 1/1] COPY --from=build /out/lab-app /lab-app #8 DONE 0.0s #9 exporting to image ... #9 DONE 0.4s
$ docker run --rm lab-app:1 version
lab-app 1.0 (go1.27.3, linux/arm64)

Read the step names you got. (The cut lines include this VM's first pull of golang:1.27-alpine.) BuildKit ran [base 1/2], [base 2/2], [build 1/1] and [runtime 1/1], and nothing from test, vet or checks. It builds the graph of stages the target needs and skips the rest. So a test stage in the same Dockerfile costs you nothing in a plain image build. [build 1/1] took 6.5s, almost all of it compiling the standard library packages that net/http pulls in. Your times depend on the CPU; the order of magnitude is what matters here. For comparison, here's the single-stage version that most projects start with:

Dockerfile.single
FROM golang:1.27-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /usr/local/bin/lab-app .
ENTRYPOINT ["lab-app"]
ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker build -q -f Dockerfile.single -t lab-app:single . docker image ls lab-app
sha256:94e5b9b8876bb3b19f036339012558c3bc4a9a614a27d82c7e02c3f202a45047 IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-app:1 81b84092b63a 7.74MB 2.29MB lab-app:single 94e5b9b8876b 493MB 95.4MB

Same source, same binary: 7.74MB against 493MB on disk. (DISK USAGE counts the unpacked layers, CONTENT SIZE the compressed ones; "Inspecting, exporting and cleaning up images" explains both columns.) The builder stages still exist in the build cache on this machine. But nothing in lab-app:1 refers to them, so they're never pushed. scratch works here because the binary is static (CGO_ENABLED=0). Choosing between scratch, distroless and Alpine is the subject of "Minimal bases: distroless, scratch, static and Alpine" in Advanced container security.

Cache mounts versus the layer cache

The layer cache ("Layers and the build cache" in Docker for beginners) is all or nothing per step. If a step's inputs changed, the step runs again from scratch. In Dockerfile.single, any edit changes COPY . ., so go build starts with an empty Go build cache every time. Make a one-line change and rebuild both versions, so you can time them:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ sed -i 's/version = "1.0"/version = "1.1"/' main.go
$ time docker build -q -f Dockerfile.single -t lab-app:single .
sha256:bb613a559eca03433ed0f8d23ea186ac1a71822a19d8b8a7be4df1afa1809ad5 real 0m11.834s user 0m0.189s sys 0m0.074s
$ time docker build -q -t lab-app:1 .
sha256:df28c08f6b09bb453c600734c7c7bdf166cfaefa59ba66a88e54fcc4ded1db9b real 0m1.242s user 0m0.091s sys 0m0.100s

That's 11.8 seconds for the single-stage rebuild and 1.2 seconds for the multi-stage one. The difference is --mount=type=cache,id=lab-go-build,target=/root/.cache/go-build on the RUN line. A cache mount is a directory BuildKit keeps between builds and mounts into the step while it runs. The step still re-ran (its source changed). But Go found the compiled standard library from the previous build in its own cache, and only recompiled main. The mount's contents never become part of a layer, so the image doesn't grow. id names the cache, and steps that use the same id share it. That's why the test and vet stages below reuse the compiler output. Without id, the target path is the id.

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker buildx du --filter type=exec.cachemount --verbose | grep -E "^(ID|Description|Type|Size|Usage count):"
ID: v9gw8gtw5elqbmxqj71zyvpsw Size: 1.487MB Description: cached mount /root/.cache/pip from exec /bin/sh -c /opt/venv/bin/pip install --require-hashes --only-binary=:all: -r /tmp/requirements.txt with id "//root/.cache/pip" Usage count: 2 Type: exec.cachemount ID: tdu0v0zadtnu1p6ciw200gw07 Size: 90.69MB Description: cached mount /root/.cache/go-build from exec /bin/sh -c CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/${APP} with id "/lab-shop-go-build" Usage count: 2 Type: exec.cachemount ID: 7akqgn564fszzzc1b2yk81fnt Size: 90.94MB Description: cached mount /root/.cache/go-build from exec /bin/sh -c go build -trimpath -ldflags="-s -w" -o /out/lab-app . with id "/lab-go-build" Usage count: 3 Type: exec.cachemount

Cache mounts are build cache records of type exec.cachemount, 90.94MB for lab-go-build here. The other two records come from other lessons' builds on this VM. docker builder prune removes cache mounts like any other build cache. Treat them as an accelerator, not storage. A cold CI runner starts with an empty one, and your build must still be correct. Some package managers lock their cache (apt, for example). For those, add sharing=locked so parallel builds wait for each other instead of corrupting it; the default is sharing=shared.

The other mount on those lines, --mount=type=bind,target=., makes the build context visible at the working directory for the length of the step. It's read-only by default. Nothing is copied into a layer, so the source tree never ends up in the build stage's filesystem. The step's cache key still covers the files it can see.

Stages in parallel

test and vet both depend only on base. Build the checks target, which needs both. -o type=cacheonly tells BuildKit to run the build without exporting an image, since a stage of marker files isn't worth keeping. "Buildx builders, outputs, cache and Bake" covers output types. time measures the whole build:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ time docker build --progress=plain --target checks -o type=cacheonly .
#0 building with "default" instance using docker driver ... #7 [vet 1/1] RUN --mount=type=bind,target=. --mount=type=cache,id=lab-go-build,target=/root/.cache/go-build go vet ./... && touch /tmp/vet.ok #7 ... #8 [test 1/1] RUN --mount=type=bind,target=. --mount=type=cache,id=lab-go-build,target=/root/.cache/go-build go test ./... && touch /tmp/test.ok #8 ... #7 [vet 1/1] RUN --mount=type=bind,target=. --mount=type=cache,id=lab-go-build,target=/root/.cache/go-build go vet ./... && touch /tmp/vet.ok #7 DONE 15.9s #8 [test 1/1] RUN --mount=type=bind,target=. --mount=type=cache,id=lab-go-build,target=/root/.cache/go-build go test ./... && touch /tmp/test.ok #8 15.97 ok lab.secopslog/app 0.002s #8 DONE 16.0s #9 [checks 1/2] COPY --from=test /tmp/test.ok / #9 DONE 0.0s #10 [checks 2/2] COPY --from=vet /tmp/vet.ok / #10 DONE 0.0s real 0m16.421s user 0m0.085s sys 0m0.067s

Step #7 (vet) and step #8 (test) both start, and each prints ... while the other one is logging. They finish within 0.1 seconds of each other at about 16 seconds. The whole build took 16.4 seconds, not the 32 that running them one after the other would take. BuildKit runs every step whose inputs are ready, so independent stages give you free parallelism. In CI this is the usual layout. You keep one Dockerfile, with a checks (or lint, test) target the pipeline builds first. The release target comes afterwards, and both share the base and the cache.

The build context and .dockerignore

Before any step runs, the client sends the build context (the directory given as the last argument) to the builder. "Writing a Dockerfile" in Docker for beginners introduced .dockerignore to keep secrets out of COPY . .. It also decides how much is sent. Add the two classic offenders, a Git object store and a log file. Then compare a build with and without the ignore file:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ mkdir -p .git && head -c 30000000 /dev/urandom > .git/pack-1.pack head -c 5000000 /dev/zero > debug.log cat .dockerignore
.git *.log secrets/ Dockerfile* .dockerignore
$ docker build --progress=plain -t lab-app:1 . 2>&1 | grep "transferring context"
#3 transferring context: 86B done #5 transferring context: 153B done
$ mv .dockerignore .dockerignore.off docker build --progress=plain -t lab-app:1 . 2>&1 | grep "transferring context" mv .dockerignore.off .dockerignore rm -rf .git debug.log
#3 transferring context: 2B done #5 transferring context: 35.01MB 0.1s done

Each build you run prints two transfers. The first (#3) is BuildKit reading .dockerignore: the file and a little metadata, and almost nothing once the file is renamed away. The second is the context. BuildKit transfers it incrementally. It sends only files that changed since the last build, plus metadata for the rest. So a rebuild of an unchanged directory moves only a few dozen bytes. With the ignore file, the new .git pack and debug.log were excluded. The context transfer stayed at that metadata-sized figure (86 bytes here). Without it, both new files went up: 35.01MB. On a laptop next to the daemon that's a fraction of a second. To a remote builder it's an upload on every build. And with --mount=type=bind,target=. the same filter decides what the build can see. The patterns follow Go's filepath.Match rules with ** support, and ! re-includes a path. A file named <Dockerfile>.dockerignore next to a Dockerfile (for example Dockerfile.single.dockerignore) overrides the shared one for builds of that Dockerfile.

Build metadata for the pipeline

Your CI jobs need the digest of what they just built, to scan it, sign it and deploy it. Don't parse it from the progress output. --metadata-file writes it as JSON:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker build -q --metadata-file metadata.json -t lab-app:1 . jq '{"image.name", "containerimage.digest", "buildx.build.ref"}' metadata.json jq -r '.["buildx.build.provenance"].materials[].uri' metadata.json
sha256:a7766f9d42e11dc53fc7967b23c7e28b20e5491db8467787be1b016785c6b321 { "image.name": "docker.io/library/lab-app:1", "containerimage.digest": "sha256:a7766f9d42e11dc53fc7967b23c7e28b20e5491db8467787be1b016785c6b321", "buildx.build.ref": "default/default/ciwrh2qj35gdy2rrtkfbleskn" } pkg:docker/golang@1.27-alpine?digest=sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c&platform=linux%2Farm64

containerimage.digest is the image index digest, the value the later pipeline stages should carry ("Tags, digests and promotion"). buildx.build.ref identifies the build record (builder/node/ID), which docker buildx history can look up later. The file also holds a summary of the provenance BuildKit recorded, and its materials list the base images by digest. "Buildx builders, outputs, cache and Bake" introduces provenance and SBOM attestations as build outputs. "Pinning, SBOMs, provenance and scanning" in Advanced container security teaches them.

Secret mounts

Some builds need a token, for a private package index or an API that serves a licence file. Such a build must not receive it as a build argument or environment variable. Those land in the image metadata or its history. "Build-time secrets and how images leak them" in Advanced container security shows each leak. The mechanism you want is a secret mount. Here you'll use a dummy token in a file:

Dockerfile.secret
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN --mount=type=secret,id=api_token,required=true \
echo "token file: $(wc -c < /run/secrets/api_token) bytes"
RUN --mount=type=secret,id=api_token,env=API_TOKEN,required=true \
echo "API_TOKEN has ${#API_TOKEN} characters in this RUN"
RUN ls /run/secrets 2>&1; echo "API_TOKEN=${API_TOKEN:-<unset>}"
ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ mkdir -p secrets && printf "lab-dummy-token-0001" > secrets/api_token.txt chmod 600 secrets/api_token.txt
$ docker build --progress=plain -f Dockerfile.secret \ --secret id=api_token,src=secrets/api_token.txt -t lab-secret:1 .
#0 building with "default" instance using docker driver ... #4 [stage-0 1/4] FROM docker.io/library/alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a #4 resolve docker.io/library/alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a 0.0s done #4 CACHED #5 [stage-0 2/4] RUN --mount=type=secret,id=api_token,required=true echo "token file: $(wc -c < /run/secrets/api_token) bytes" #5 0.096 token file: 20 bytes #5 DONE 0.1s #6 [stage-0 3/4] RUN --mount=type=secret,id=api_token,env=API_TOKEN,required=true echo "API_TOKEN has ${#API_TOKEN} characters in this RUN" #6 0.127 API_TOKEN has 20 characters in this RUN #6 DONE 0.1s #7 [stage-0 4/4] RUN ls /run/secrets 2>&1; echo "API_TOKEN=${API_TOKEN:-<unset>}" #7 0.118 ls: /run/secrets: No such file or directory #7 0.118 API_TOKEN=<unset> #7 DONE 0.1s ...

--secret id=api_token,src=... makes the file available to the build under the id api_token. In the first RUN it's a file at /run/secrets/api_token; target= would put it elsewhere. In the second, env=API_TOKEN exposes it as an environment variable for that one command. The third RUN has no mount, and neither the file nor the variable exists there. You should see three behaviours before you rely on this in CI:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ LAB_TOKEN=lab-dummy-token-0002-rotated docker build --progress=plain -f Dockerfile.secret \ --secret id=api_token,env=LAB_TOKEN -t lab-secret:1 . 2>&1 | grep -E "^#[0-9]+ (\[|CACHED)"
#1 [internal] load build definition from Dockerfile.secret #2 [internal] load metadata for docker.io/library/alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a #3 [internal] load .dockerignore #4 [stage-0 1/4] FROM docker.io/library/alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a #5 [stage-0 2/4] RUN --mount=type=secret,id=api_token,required=true echo "token file: $(wc -c < /run/secrets/api_token) bytes" #5 CACHED #6 [stage-0 3/4] RUN --mount=type=secret,id=api_token,env=API_TOKEN,required=true echo "API_TOKEN has ${#API_TOKEN} characters in this RUN" #6 CACHED #7 [stage-0 4/4] RUN ls /run/secrets 2>&1; echo "API_TOKEN=${API_TOKEN:-<unset>}" #7 CACHED
$ docker build -q -f Dockerfile.secret -t lab-secret:1 .
sha256:53d3490003a859a6d42d481067c4117f271c0c94678e6d53870f79eecb5b7548
$ docker build --progress=plain --no-cache -f Dockerfile.secret -t lab-secret:1 .
#0 building with "default" instance using docker driver ... #5 [stage-0 2/4] RUN --mount=type=secret,id=api_token,required=true echo "token file: $(wc -c < /run/secrets/api_token) bytes" #5 ERROR: secret api_token: not found ------ > [stage-0 2/4] RUN --mount=type=secret,id=api_token,required=true echo "token file: $(wc -c < /run/secrets/api_token) bytes": ------ Dockerfile.secret:2 -------------------- 1 | FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a 2 | >>> RUN --mount=type=secret,id=api_token,required=true \ 3 | >>> echo "token file: $(wc -c < /run/secrets/api_token) bytes" 4 | RUN --mount=type=secret,id=api_token,env=API_TOKEN,required=true \ -------------------- ERROR: failed to build: failed to solve: secret api_token: not found

First, the rotated token (passed from an environment variable with env=LAB_TOKEN this time) didn't re-run anything; every step is CACHED. Secret contents are deliberately left out of the cache key. So a build that must react to a new secret needs --no-cache or a changed input. Second, a build with no secret at all succeeded, because the steps came from cache and never asked for it. required=true is enforced only when the step actually runs. Third, with --no-cache the same build fails with secret api_token: not found, as it should. Finally, the image:

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker history --no-trunc --format "{{.CreatedBy}}" lab-secret:1 | head -3 docker run --rm lab-secret:1 ls /run/secrets
RUN /bin/sh -c ls /run/secrets 2>&1; echo "API_TOKEN=${API_TOKEN:-<unset>}" # buildkit RUN /bin/sh -c echo "API_TOKEN has ${#API_TOKEN} characters in this RUN" # buildkit RUN /bin/sh -c echo "token file: $(wc -c < /run/secrets/api_token) bytes" # buildkit ls: /run/secrets: No such file or directory

History records the commands, not the mount options or values, and /run/secrets doesn't exist in the image. What the step writes to its own filesystem is another matter. A RUN that copies the secret into a file, or prints it into a log the build keeps, leaks it like any other content.

SSH mounts

When you clone a private Git repository during a build, you need an SSH key, and a copied key file is a leaked key. With --ssh you forward an SSH agent instead. The build can ask the agent to sign, but it never sees the key. The lab creates a throwaway key with no passphrase and a temporary agent. Never use a real key for this exercise.

Dockerfile.ssh
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN apk add --no-cache openssh-client
RUN --mount=type=ssh \
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" && ssh-add -l
RUN ssh-add -l; echo "exit status without the mount: $?"
ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ ssh-keygen -q -t ed25519 -N "" -C lab-build-key -f lab_ed25519 ssh-keygen -lf lab_ed25519.pub
256 SHA256:QQqU0Ahg4iYEMdOqAQs+HP11FrAk3E02i8+EJPj0TBc lab-build-key (ED25519)
$ eval "$(ssh-agent -s)" >/dev/null && ssh-add -q lab_ed25519 docker build --progress=plain -f Dockerfile.ssh --ssh default -t lab-ssh:1 . ssh-agent -k >/dev/null
#0 building with "default" instance using docker driver ... #5 [stage-0 2/4] RUN apk add --no-cache openssh-client ... #5 DONE 5.2s #6 [stage-0 3/4] RUN --mount=type=ssh echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" && ssh-add -l #6 0.153 SSH_AUTH_SOCK=/run/buildkit/ssh_agent.0 #6 0.161 256 SHA256:QQqU0Ahg4iYEMdOqAQs+HP11FrAk3E02i8+EJPj0TBc lab-build-key (ED25519) #6 DONE 0.2s #7 [stage-0 4/4] RUN ssh-add -l; echo "exit status without the mount: $?" #7 0.093 Could not open a connection to your authentication agent. #7 0.093 exit status without the mount: 2 #7 DONE 0.1s ...

--ssh default forwards the agent named by SSH_AUTH_SOCK in your shell. Inside the step with --mount=type=ssh, SSH_AUTH_SOCK points at a socket BuildKit provides. There ssh-add -l lists the same fingerprint as the key on the VM (fingerprints differ on every run, since the key is new). The next step, without the mount, has no agent. --ssh default=./lab_ed25519 would load an unencrypted key file without an agent. For a real clone the step also needs the server's host key, otherwise ssh refuses to connect:

Dockerfile (excerpt)
RUN apk add --no-cache git openssh-client
# known_hosts holds GitHub's published host keys, committed to the repository
COPY --chmod=0644 known_hosts /root/.ssh/known_hosts
RUN --mount=type=ssh git clone git@github.com:example-org/private-lib.git /src/private-lib

Many examples run ssh-keyscan github.com in the build instead. That trusts whatever answers at build time, which is exactly the check a host key exists for. Instead, copy the keys from GitHub's published fingerprints page (docs.github.com, "GitHub's SSH key fingerprints") into a known_hosts file once. Review it like any other change, and copy it in as above.

Clean up

ubuntu@secopslog-docker:~/lab/multistage · Docker 29.9.2
$ docker image rm lab-app:1 lab-app:single lab-secret:1 lab-ssh:1 docker builder prune -f --filter type=exec.cachemount --filter "description~=lab-go-build" rm -rf metadata.json secrets lab_ed25519 lab_ed25519.pub
Untagged: lab-app:1 Deleted: sha256:a7766f9d42e11dc53fc7967b23c7e28b20e5491db8467787be1b016785c6b321 Untagged: lab-app:single Deleted: sha256:bb613a559eca03433ed0f8d23ea186ac1a71822a19d8b8a7be4df1afa1809ad5 Untagged: lab-secret:1 Deleted: sha256:53d3490003a859a6d42d481067c4117f271c0c94678e6d53870f79eecb5b7548 Untagged: lab-ssh:1 Deleted: sha256:ea32b96e673174309b4058f0075baf679cc5966f5eb5d0da7a2417a738c9f0da ID RECLAIMABLE SIZE LAST ACCESSED 7akqgn564fszzzc1b2yk81fnt* true 202.7MB 9 seconds ago Total: 202.7MB

The docker builder prune filter removes the Go cache mount by its id, so other build cache on the VM stays. If something was left behind, /opt/secopslog-lab/setup/reset-lab.sh --images removes everything lab-related.

Try this

Before you clean up, make a second one-line edit and rebuild only Dockerfile.single. First predict whether it lands near the 11.8 seconds above or near the 1.2 seconds of the multi-stage rebuild. Then rebuild the default target after the same edit and compare. The gap you see is the Go build cache mount, since both rebuilds re-run the compile step.

Takeaway

Compile in one stage, copy only the binary into the final one, and give the compile step a cache mount. Here that took the image from 493MB to 7.74MB and a rebuild from 11.8 to 1.2 seconds. On CI runners that start cold the cache mount is empty, so measure the first build too. Never pass tokens as build arguments; and remember a cached step won't notice a rotated secret, and won't fail on a missing one until --no-cache forces it to run.

Quick check
01A Dockerfile has stages deps, build, lint and release, where release copies from build and lint starts from deps. CI runs docker build -t app:1.4 .. Which stages run?
Incorrect — That was the legacy builder's behaviour. BuildKit builds only the stages the target needs.
Correct — release is the default target; it needs build, which needs deps. lint is not on that path, so it is skipped.
Incorrect — Caching skips work for unchanged steps, but the needed stages are still part of the build graph and run when their inputs changed.
Incorrect — Parallel execution applies to stages the target needs. Nothing pulls lint in, so it does not run at all.
02Your build passes --secret id=npm_token,src=token.txt. After rotating the token you rebuild; the build is green and the RUN --mount=type=secret,id=npm_token npm ci step shows CACHED. Was the new token used, and why?
Incorrect — A CACHED step does not run at all, so nothing is mounted into it.
Incorrect — Secret contents are not hashed into the cache key at all, so there is nothing to compare against.
Incorrect — That is true for COPY sources, not for secrets. The lab rotated the token and every step stayed CACHED.
Correct — CACHED means the old result was reused and npm never ran with the new token. Use --no-cache (or change an input) when a new secret must take effect.
03Why did the multi-stage rebuild in the lab take about 1 second while the single-stage rebuild took about 12?
Correct — The step still re-ran after the edit, but Go reused the standard library it had compiled into the cache mount and only recompiled main.
Incorrect — The source changed, so the step re-ran. It was fast, not cached.
Incorrect — The final base barely matters for build time. The compile step dominates in both builds.
Incorrect — A bind mount copies nothing; it exposes the context for one step. Speed came from the Go build cache in the cache mount.

Related