BuildKit and Buildx: Cache Mounts, Build Secrets, Multi-Platform
Persist package caches across builds, use credentials without leaking them, and build for ARM and x86 at once — with the default builder.
The Builder You're Already Using
BuildKit is the default builder in modern Docker. If you've run docker build recently, you've used it — you can tell from the neat, parallel, collapsing progress output rather than the old linear Step 3/12 : list.
Most people never go further than that. But BuildKit isn't a faster version of the old builder; it's a different engine with capabilities the old one couldn't express, and three of them solve problems from the previous guide outright.
BuildKit also analyses your Dockerfile as a graph rather than a list, so independent stages build in parallel, and any stage whose output isn't actually needed is skipped entirely.
Some BuildKit features require a Dockerfile frontend version that supports them. Adding # syntax=docker/dockerfile:1 as the very first line of your Dockerfile opts into the latest stable frontend, which BuildKit fetches automatically. It's one line and it prevents a class of confusing "unknown flag" errors.
Cache Mounts: Stop Re-Downloading the Internet
The previous guide showed how to order instructions so a dependency-install layer stays cached. But when that layer does rebuild — because you genuinely added a dependency — you download everything again from scratch, including the hundreds of packages that didn't change.
A cache mount attaches a persistent directory to a single RUN step. It lives outside the image, survives across builds, and isn't part of any layer:
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
CMD ["node", "server.js"]Now adding one dependency downloads one package instead of all of them. The same pattern applies everywhere:
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
RUN --mount=type=cache,target=/go/pkg/mod go build ./...
RUN --mount=type=cache,target=/root/.m2 mvn packageFor tools that can't cope with concurrent access to their cache, add sharing=locked so parallel builds wait for each other rather than corrupting it:
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y curlBecause a cache mount lives outside the image, the cached files add nothing to your final image size. This is strictly better than the old workaround of installing, then deleting the cache in the same RUN — you get the speed benefit and the size benefit.
Secret Mounts: the Fix for the Most Common Leak
The last guide established the problem: a credential that enters your build through COPY or ARG ends up in the image, where docker history will find it. Deleting it later doesn't help.
A secret mount makes a credential available as a file during one RUN step, and nowhere else. It is never written to a layer and never recorded in image metadata.
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmtoken \
NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci
COPY . .And at build time:
docker build --secret id=npmtoken,src=$HOME/.npmrc -t myapp .Secrets mount at /run/secrets/<id> by default; target= overrides the path. For cloning private Git repositories there's a dedicated SSH mount:
RUN --mount=type=ssh git clone git@github.com:me/private-repo.gitdocker buildx build --ssh default -t myapp .Be clear about what ARG is and isn't. ARG is for build-time configuration — a version number, a target environment. It is not a secret mechanism: its values are visible in image metadata and in docker history. If a value would hurt you when published, it needs a secret mount, not an ARG. And if one has already shipped in an image, the response is to rotate the credential — removing the image doesn't un-publish what was pulled.
Check yourself
A Dockerfile installs private packages using ARG GITHUB_TOKEN, passed in CI via --build-arg. A security review flags it. Which response actually fixes the problem?
Multi-Platform Builds
If you develop on an Apple Silicon Mac and deploy to x86 servers, you've probably met this error in production:
exec format error
That's a CPU architecture mismatch. Your laptop built an arm64 image; the server runs amd64. The image is valid — it's just compiled for the wrong processor.
docker buildx builds for several architectures at once and publishes them under a single tag:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myregistry/myapp:1.4.0 \
--push .The registry then holds a manifest list — an index pointing at one image per architecture. When a host pulls that tag, it automatically gets the build matching its own CPU. One tag, correct everywhere, no branching in your deploy scripts.
Multi-platform builds for a foreign architecture are emulated via QEMU unless you have native builders, and emulated builds can be dramatically slower — enough to reshape a CI pipeline's runtime. If cross-architecture builds are slow, the usual answer is a builder with native nodes for each architecture rather than more emulation.
Buildx Bake: When One Build Isn't Enough
Once you're building several images — an API, a worker, a migration job — the docker buildx build commands multiply and drift apart. buildx bake moves them into a file:
# docker-bake.hcl
group "default" {
targets = ["api", "worker"]
}
target "api" {
context = "."
dockerfile = "Dockerfile.api"
platforms = ["linux/amd64", "linux/arm64"]
tags = ["myregistry/api:dev"]
}
target "worker" {
context = "."
dockerfile = "Dockerfile.worker"
platforms = ["linux/amd64", "linux/arm64"]
tags = ["myregistry/worker:dev"]
}Then docker buildx bake builds both, in parallel, with identical settings in local development and CI. It's the same idea as Compose, applied to builds rather than to running containers.
Check yourself
A cache mount and a regular image layer both store downloaded packages. What's the practical difference for your final image?
What's Next
BuildKit gives you fast, safe builds. The remaining size problem — an image carrying a compiler, headers and dev dependencies it will never use at runtime — is solved by multi-stage builds, together with a deliberate choice of base image. That's the next guide, and it's where the 2.2GB image finally becomes 145MB.