Your Dockerfile builds are glacial. I know they are. You’re waiting for pip install to re-download every package, npm to reinstall node_modules even though you didn’t touch package.json, and apt-get to fetch the same packages across three different build stages.
You enabled DOCKER_BUILDKIT=1 six months ago, noticed the output looked fancier, and called it a day.
That’s like buying a Ferrari and driving it at 35 mph.
BuildKit isn’t just prettier output. It’s a fundamentally different build engine with cache mounts, secret mounts, parallel stages, and context optimizations that can knock 80% off your build time. Most teams leave 90% of this on the table because the defaults just kinda work.
Let’s fix that.
What Even Is BuildKit?
Before BuildKit, Docker’s build engine was synchronous and single-threaded: execute each layer, push the result, move to the next layer. If your Dockerfile had three RUN instructions, they ran one after another, always.
BuildKit (introduced Docker 19.03, production-ready 20.10+) is a new builder that:
- Treats your Dockerfile as a dependency graph, not a sequence
- Caches layers like a developer who doesn’t know how to rm -rf
- Allows parallel build stages (you can build multiple targets at once)
- Mounts secrets, SSH keys, and cache volumes without baking them into the image
- Prunes unused build artifacts
Enable it:
export DOCKER_BUILDKIT=1docker build -t myapp .Or for a cleaner UX with multi-platform support, use docker buildx:
docker buildx create --name builderdocker buildx use builderdocker buildx build -t myapp .Cache Mounts: Stop Reinstalling Everything
Here’s the killer feature that actually saves time.
Normally, if you have a pip install -r requirements.txt layer and you add a new dependency, Docker rebuilds that layer from scratch. The cache misses, and pip has to download 200 MB of packages all over again.
Cache mounts keep directories persistent across builds without baking them into the image.
# syntax=docker/dockerfile:1.4
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
# Mount the pip cache, persist between buildsRUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txt
COPY . .CMD ["python", "app.py"]That --mount=type=cache line tells BuildKit: “Keep this directory on the build machine, don’t bake it into the image, and reuse it next time you build.”
Real numbers: first build takes 90 seconds (pip downloads everything). You add one dependency to requirements.txt. Next build? 15 seconds. Pip just downloads the new package, everything else was cached.
Same principle for Node:
# syntax=docker/dockerfile:1.4
FROM node:20-alpine
WORKDIR /app
COPY package*.json .
RUN --mount=type=cache,target=/root/.npm \ npm ci --cache /root/.npm
COPY . .RUN npm run buildCMD ["npm", "start"]And for apt (the silent killer in multi-stage builds):
# syntax=docker/dockerfile:1.4
FROM ubuntu:24.04
RUN --mount=type=cache,target=/var/cache/apt \ apt-get update && apt-get install -y \ curl wget build-essential && \ rm -rf /var/lib/apt/lists/*That cache mount is the difference between a 3-second rebuild and waiting for apt to re-fetch 500 MB of metadata.
Secret Mounts: Stop Baking Credentials Into Layers
You know that panic moment when you realize you accidentally committed your GitHub SSH key in the Dockerfile build log? You should.
Secret mounts let you pass SSH keys, API tokens, and passwords into the build without them ever touching the filesystem or the image layers.
# syntax=docker/dockerfile:1.4
FROM golang:1.23-alpine
WORKDIR /app
# Clone a private repo without baking the SSH key into the imageRUN --mount=type=secret,id=github_key,target=/root/.ssh/id_ed25519 \ mkdir -p /root/.ssh && \ chmod 600 /root/.ssh/id_ed25519 && \ ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null && \ git clone git@github.com:yourorg/private-repo.git .
RUN go mod downloadRUN go build -o app .CMD ["./app"]Build it like this:
docker buildx build \ --secret id=github_key,src=$HOME/.ssh/id_ed25519 \ -t myapp .The key is mounted into the build container but never committed to any layer. Once the layer finishes, the mount disappears. You can inspect the final image with docker inspect, and that SSH key? Gone. Buried. Safe.
Same for API tokens, database passwords, anything sensitive:
docker buildx build \ --secret id=npm_token,src=/path/to/.npmrc \ --secret id=pypi_token,src=/path/to/.pypirc \ -t myapp .Parallel Build Stages
Your multi-stage Dockerfile builds each stage sequentially. BuildKit can build them in parallel.
# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json .RUN --mount=type=cache,target=/root/.npm \ npm ci --cache /root/.npmCOPY . .RUN npm run build
FROM python:3.12-slim AS apiWORKDIR /apiCOPY requirements.txt .RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txtCOPY . .CMD ["python", "app.py"]
FROM nginx:latestCOPY --from=builder /app/dist /usr/share/nginx/htmlCOPY --from=api /api /apiCMD ["nginx", "-g", "daemon off;"]With BuildKit, the builder and api stages build simultaneously. Without it, they run one after the other. That’s a free speed-up just sitting there.
The Silent Killer: .dockerignore
Your build context is everything Docker has to send to the builder. If your project directory has 500 MB of node_modules, a .git folder, and test coverage reports, all of that gets uploaded to the daemon before the first layer runs.
.dockerignore is your friend:
node_modules/.git/.pytest_cache/dist/build/*.log.venv/coverage/.env*That single file can cut your context size from 500 MB to 50 MB. Your build starts faster. The daemon has less work to do.
docker buildx bake: Multi-Platform Builds
You want to ship an image for both ARM64 (Raspberry Pi) and AMD64 (your server). Separate Dockerfiles? No.
Create a docker-bake.hcl:
group "default" { targets = ["app"]}
target "app" { dockerfile = "Dockerfile" platforms = ["linux/amd64", "linux/arm64"] tags = ["myapp:latest"] output = ["type=image"]}docker buildx bakeDone. One command, two platforms, one image repository.
CI/CD: GitHub Actions + BuildKit Cache
Your GitHub Actions build is uploading a fresh image every time. BuildKit has caching — use it.
name: Build & Pushon: [push]jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v5 with: push: true tags: ${{ secrets.REGISTRY }}/myapp:latest cache-from: type=gha cache-to: type=gha,mode=maxThat cache-from and cache-to lines: GitHub’s Actions cache backend. Your pip and npm caches persist between runs. A rebuild that would’ve taken 120 seconds now takes 30. Because your dependencies were already cached in GitHub’s infrastructure.
The Checklist
Before you push that Dockerfile to production:
- Is
DOCKER_BUILDKIT=1set in your CI pipeline? ✅ - Do you have cache mounts for slow operations (pip, npm, apt)? ✅
- Are secrets being passed as mounts, not COPY+RUN? ✅
- Is your
.dockerignoreaggressive? ✅ - Are you using
docker buildx bakefor multi-platform? ✅ - In CI, are you using the GitHub Actions cache backend? ✅
Your builds aren’t slow because Docker is bad. They’re slow because you’re leaving 80% of BuildKit’s power on the table.
Fix it.