Skip to content
Go back

Docker BuildKit: Stop Building Images the Slow Way

By SumGuy 6 min read
Docker BuildKit: Stop Building Images the Slow Way

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:

Enable it:

Terminal window
export DOCKER_BUILDKIT=1
docker build -t myapp .

Or for a cleaner UX with multi-platform support, use docker buildx:

Terminal window
docker buildx create --name builder
docker buildx use builder
docker 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.

Dockerfile
# syntax=docker/dockerfile:1.4
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
# Mount the pip cache, persist between builds
RUN --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:

Dockerfile
# 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 build
CMD ["npm", "start"]

And for apt (the silent killer in multi-stage builds):

Dockerfile
# 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.

Dockerfile
# syntax=docker/dockerfile:1.4
FROM golang:1.23-alpine
WORKDIR /app
# Clone a private repo without baking the SSH key into the image
RUN --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 download
RUN go build -o app .
CMD ["./app"]

Build it like this:

Terminal window
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:

Terminal window
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.

Dockerfile
# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN --mount=type=cache,target=/root/.npm \
npm ci --cache /root/.npm
COPY . .
RUN npm run build
FROM python:3.12-slim AS api
WORKDIR /api
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
FROM nginx:latest
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=api /api /api
CMD ["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"]
}
Terminal window
docker buildx bake

Done. 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 & Push
on: [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=max

That 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:

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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
LiteLLM & vLLM: One API to Rule All Your Models
Next Post
Stable Diffusion vs ComfyUI vs Fooocus: AI Image Generation at Home

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts