Faster, smaller Docker images: practical patterns with BuildKit, Buildx cache and minimal base images

Building Docker images is part craft, part engineering: you want fast, reproducible builds in CI and tiny, secure images in production. Over the last few years the tooling around Docker builds—BuildKit, buildx, caching backends and slimmer base images—has matured. This article walks through pragmatic patterns you can apply today to speed CI, shrink images, and reduce surprises in production.

Why this matters (quick)

The patterns below work for most language ecosystems (Go, Node, Python, Java) and assume you can use Docker BuildKit / buildx in your environment.

Core idea 1 — use BuildKit and export/import build cache BuildKit is the modern build engine that enables parallelism, better layer reuse, and cache exporters/importers. When you pair BuildKit/buildx with a cache backend (registry, GitHub Actions, local) you can export build cache after a CI run and import it on the next run — saving large amounts of rebuild time for unchanged dependencies. The buildx flags –cache-to and –cache-from (and inline cache options) are the mechanisms to push and pull those caches. (docs.docker.com)

Practical command (buildx)

Core idea 2 — multi-stage builds: compile and discard build-time baggage Multi-stage builds are the single most effective Dockerfile pattern for smaller images. Build what you need in earlier stages (with compilers, build tools, dev dependencies), then copy only the runtime artifacts into a minimal final stage. This keeps the final image focused and tiny. The official Docker guidance recommends multi-stage builds as a standard best practice. (docs.docker.com)

Example (Go)

# stage 1: build
FROM golang:1.20 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/main ./cmd/service

# stage 2: runtime (scratch for smallest possible)
FROM scratch
COPY --from=builder /app/main /app/main
EXPOSE 8080
CMD ["/app/main"]

If you need CA certificates or libc, replace scratch with a tiny base (distroless or slim) and copy certs as needed.

Core idea 3 — pick the right base image: slim vs distroless vs scratch Base image choice matters. A full distro image (ubuntu) includes many packages your app will never use; slim variants (node:XX-slim, python:XX-slim) remove extra tooling; distroless images (from Google) include only the runtime and essential libraries; scratch is literally empty and works great for statically linked binaries like Go.

Which to use?

Core idea 4 — order layers for cache friendliness Docker builds cache each layer. Small changes to a layer invalidate everything after it. Treat Dockerfile layering like songwriting: put the stable riffs (dependencies) early and the changing chorus (source code) last. For example:

Core idea 5 — squash unnecessary layers and minimize RUN invocations Each RUN, COPY, and ADD creates a layer. Combine commands with && and clean up package caches in the same RUN to avoid keeping temporary files:

RUN apt-get update && apt-get install -y \
    build-essential \
  && rm -rf /var/lib/apt/lists/*

For language package managers:

Core idea 6 — use inline cache for single-image workflows and reproducibility BuildKit supports inline cache metadata embedded into the built image. That means subsequent builds that pull that image as –cache-from can use its cache without a separate cache export. Set BUILDKIT_INLINE_CACHE=1 during build and then push the image — future builds can pull layer metadata from that image. This is especially handy if you don’t have a dedicated cache store. (dockerbuild.com)

Practical example (inline cache)

DOCKER_BUILDKIT=1 docker build --build-arg BUILDKIT_INLINE_CACHE=1 -t myorg/myapp:sha123 .
docker push myorg/myapp:sha123
# next build
docker build --cache-from myorg/myapp:sha123 -t myorg/myapp:sha456 .

Core idea 7 — CI integration: use buildx and cache backends inside pipelines In CI (GitHub Actions, GitLab CI), use docker/setup-buildx-action and docker/build-push-action (or the equivalent) to enable buildx and export cache to your chosen backend. Docker’s docs describe backends including registry and a GitHub Actions cache backend (gha). This allows your CI runs to reuse cache across different runners and workflow runs. (docs.docker.com)

Common pitfalls and how to avoid them

Real-world checklist (quick)

Why this approach saves money and time

Closing note (a small analogy) Think of your Dockerfile like arranging a song for live performance. You want the rhythm section (dependencies) tightly rehearsed and in place, so when the solo (source code) changes you don’t need to re-record the whole track. BuildKit gives you the soundboard and the ability to save those rehearsals (cache) to the cloud — so next time you arrive at the studio, most of the work is already done.

Selected references

Happy building — keep your builds cached, your images minimal, and your deploys fast.