Docker BuildKit: Faster, Secure Image Builds Without the Legacy Cruft
Docker BuildKit speeds up builds with parallel execution and caching.
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
- ✓Basic Dockerfile syntax
- ✓Familiarity with docker build command
- ✓Understanding of image layers
Enable BuildKit by setting DOCKER_BUILDKIT=1 or using docker buildx. It parallelizes independent stages, caches more aggressively, and supports secure mounts for secrets and SSH keys. To use it, just export the env var or install buildx.
Imagine you're assembling furniture. The old builder makes you do each step one at a time, even if you could screw legs while someone else attaches the back panel. BuildKit is like having a second pair of hands — it works on multiple parts simultaneously. It also remembers what you've done before, so if you redo a step, it doesn't redo everything from scratch.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
You're still using the legacy Docker builder? Your builds are 2x slower than they need to be. And you're probably leaking secrets into your image layers. Docker BuildKit has been the default since Docker 23.0, but half the industry still hasn't flipped the switch. That's a production incident waiting to happen. By the end of this, you'll have BuildKit enabled, know how to use its killer features (parallel builds, cache mounts, secret mounts), and understand the gotchas that'll bite you in CI.
Why BuildKit? The Legacy Builder's Dirty Secrets
The legacy builder is a relic from 2013. It executes Dockerfile instructions sequentially, one layer at a time. No parallelism. No intelligent caching. And it stores every build context in the daemon's temp space — so if you have multiple concurrent builds, they fight for disk I/O. I've seen CI pipelines take 45 minutes because the legacy builder re-downloaded the same apt packages every single build. BuildKit fixes this with a DAG-based execution model: independent stages run in parallel, and caching is content-addressable (based on input hashes, not just command strings). It also supports frontends like dockerfile.v1.0 that can optimize the build graph. The result: builds are 2-4x faster on multi-stage Dockerfiles.
Parallel Stage Execution: The Real Speed Boost
Multi-stage Dockerfiles are the norm: one stage for building, another for runtime. The legacy builder runs them sequentially — build stage finishes, then runtime stage starts. BuildKit runs independent stages in parallel. If your build stage compiles a Go binary and your runtime stage copies it, they can't run in parallel (dependency). But if you have two independent build stages (e.g., frontend and backend), BuildKit runs them simultaneously. This is huge for monorepos. Example: a Java backend and a React frontend build in parallel, cutting total build time from 10 minutes to 6. The gotcha: BuildKit only parallelizes stages that don't depend on each other. If you use COPY --from=build-stage, that stage must finish first. So structure your Dockerfile to maximize independence.
Cache Mounts: Stop Re-downloading Dependencies
The legacy builder has no concept of persistent cache across builds. Every time you change a source file, the RUN apt-get update or RUN npm install layer invalidates, and you re-download everything. BuildKit introduces cache mounts: a persistent directory that survives between builds. You mount it at the package manager's cache location. For npm, that's /root/.npm. For apt, /var/cache/apt. For Go, /root/.cache/go-build. The mount is not part of the final image — it's ephemeral storage on the build host. This cuts build time for dependency-heavy projects by 70%. The trade-off: cache mounts can grow unbounded. Set a size limit with --mount=type=cache,target=/root/.npm,size=500m. Also, cache mounts are shared across concurrent builds of the same Dockerfile — that's fine for CI, but can cause race conditions if you're not careful with lock files.
Secret Mounts: Stop Leaking Credentials into Images
The classic rookie mistake: passing secrets via ARG or COPY. Those secrets end up in the image history, accessible to anyone who can pull the image. I've seen AWS keys in public Docker Hub images. BuildKit's --secret mount solves this: it mounts a secret file at build time, but the secret never becomes part of the image layers. You pass the secret via --secret id=mysecret,src=./secret.txt in the build command. In the Dockerfile, you mount it with RUN --mount=type=secret,id=mysecret. The secret is available only during that RUN command. After the command, it's gone. This is the only safe way to use private npm registries, install private packages, or authenticate to APIs during build.
SSH Mounts: Secure Git Clones During Build
Cloning private repositories during a build is a pain. You either COPY your SSH keys (bad) or use an SSH agent forward (complex). BuildKit's --ssh mount solves this: it forwards your host's SSH agent socket into the build container. No keys are ever copied. You pass --ssh default in the build command, and in the Dockerfile, you mount with RUN --mount=type=ssh. This is perfect for go mod download with private modules or pip install from private repos. The gotcha: the SSH agent must be running on the host. In CI, you need to set up the agent with the private key. Also, the mount only works for the duration of that RUN command — subsequent RUN commands don't have SSH access unless you mount again.
Remote Cache: Speed Up CI Across Machines
Local cache is great for your dev machine. But in CI, each runner is ephemeral — no cache persists between runs. BuildKit supports remote cache backends: registry, S3, GCS, and more. You push cache to a registry (or S3 bucket) after a successful build, and pull it on the next build. This means the second CI run is as fast as the first if nothing changed. The syntax: --cache-to type=registry,ref=myrepo:cache --cache-from type=registry,ref=myrepo:cache. The cache is stored as a manifest in the registry. It's not a separate image — it's metadata that BuildKit uses to skip layers. The gotcha: cache size can be large. Use mode=max to cache all layers, or mode=min to cache only the final stage. Also, cache invalidation is based on input hashes — if you change a base image version, the cache for that stage is invalidated.
When BuildKit Breaks: Gotchas from the Trenches
BuildKit isn't magic. It has sharp edges. First: cache mounts can cause race conditions if two builds write to the same cache simultaneously. I've seen npm install fail with 'Lock file not found' because two builds were modifying the same /root/.npm cache. Fix: use a unique cache ID per branch or per job (e.g., --mount=type=cache,id=npm-${BRANCH}). Second: BuildKit's output is different. The legacy builder shows each step with a green/red status. BuildKit shows a stream of JSON logs. If your CI parses the output, it'll break. Third: some Dockerfile features are not supported in BuildKit's default frontend (dockerfile.v1.0). For example, ARG before FROM is parsed differently. Test your Dockerfile with BuildKit before rolling out. Fourth: BuildKit uses its own build context handling. If you use .dockerignore, make sure it's correct — BuildKit is stricter about ignoring files.
Migrating from Legacy: A Safe Rollout Plan
Don't flip the switch globally on a Friday. Here's the plan: 1) Set DOCKER_BUILDKIT=1 on a single developer's machine. Build your project. Check for errors. 2) If your Dockerfile uses ARG before FROM, test carefully. 3) Enable BuildKit in CI for one job. Monitor build time and success rate. 4) If you use remote cache, start with mode=min to limit cache size. 5) Once stable, enable globally. The biggest risk: your Dockerfile might rely on legacy behavior (e.g., implicit cache invalidation). BuildKit's caching is smarter — it might reuse a layer when you expected it to rebuild. Use --no-cache if you need a clean build. Also, if you use docker-compose, set COMPOSE_DOCKER_CLI_BUILD=1 to use BuildKit.
The 4GB Container That Kept Dying
- Cache mounts aren't just for speed — they prevent layer bloat that kills containers.
docker version --format '{{.Server.Version}}'echo $DOCKER_BUILDKIT| File | Command / Code | Purpose |
|---|---|---|
| EnableBuildKit.sh | export DOCKER_BUILDKIT=1 | Why BuildKit? The Legacy Builder's Dirty Secrets |
| ParallelBuild.dockerfile | FROM golang:1.21 AS backend-builder | Parallel Stage Execution |
| CacheMount.dockerfile | FROM node:18 AS build | Cache Mounts |
| SecretMount.dockerfile | FROM node:18 AS build | Secret Mounts |
| SSHMount.dockerfile | FROM golang:1.21 AS build | SSH Mounts |
| RemoteCache.sh | docker buildx build \ | Remote Cache |
| CacheRaceFix.dockerfile | RUN --mount=type=cache,id=npm-${BRANCH},target=/root/.npm \ | When BuildKit Breaks |
| Migrate.sh | export DOCKER_BUILDKIT=1 | Migrating from Legacy |
Key takeaways
Interview Questions on This Topic
How does BuildKit's content-addressable caching differ from the legacy builder's caching, and what problem does it solve?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
That's Docker. Mark it forged?
4 min read · try the examples if you haven't