Faster, smaller Docker images: practical tricks with BuildKit, Buildx, and cache-aware Dockerfiles

Containers are like a band touring the world: the lighter and better-organized the gear, the faster you can move between venues. In the world of Docker, that gear is your image layers, build cache, and the Dockerfile that arranges them. This article walks through practical, modern techniques—focusing on BuildKit and Buildx—to speed CI builds and shrink runtime images without assuming deep systems knowledge.

Why build performance matters (and what’s changed)

Two common problems show up again and again:

Recent Docker tooling—BuildKit and the Buildx front-end—makes addressing both easier by exposing better caching, cache export/import, and advanced Dockerfile primitives like cache and bind mounts. Those features let you persist build caches across ephemeral CI builders and avoid re-downloading packages on every run. (docs.docker.com)

Key ideas at a glance

A compact Dockerfile pattern

Here’s a typical modern pattern for a Node app using multi-stage builds, cache mounts, and layer ordering. It’s written with the BuildKit Dockerfile syntax in mind:

# syntax=docker/dockerfile:1.5
FROM node:20-alpine AS deps
WORKDIR /app
# Cache npm downloads between builds
RUN --mount=type=cache,target=/root/.npm \
    npm ci --silent

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /root/.npm /root/.npm
COPY package.json package-lock.json ./
RUN npm ci --silent
COPY . .
RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
# Only keep what you need
CMD ["node", "dist/index.js"]

Notes:

Exporting cache across CI builds

Local builders keep an internal cache, but CI runners are usually ephemeral. Buildx supports exporting the build cache to external backends (registry, S3, GitHub Actions cache) and importing it back on later builds. That makes cache reuse across CI runs practical and predictable. Example commands:

Those cache mechanisms are supported by Docker’s Buildx and allow CI pipelines to reuse prior work. Note that different exporters (registry, local, gha, s3, azblob, inline) exist and have trade-offs depending on your CI provider. (docs.docker.com)

Cache mounts vs. classic layering: an analogy

Classic image layers are like snapshots in a photo album—every small change produces a new photo. Cache mounts are more like an external hard drive you keep in your backpack: the build uses it, writes updates, and the contents persist between trips. Because cache mounts aren’t baked into image layers, they avoid invalidating the layer graph when unrelated files change, which makes rebuilds much faster. This is particularly helpful for package managers (npm, pip, apt, cargo) that otherwise re-download many files. (docs.docker.com)

Small runtime images: distroless and scratch

If minimizing runtime size and attack surface matters, distroless or scratch images are reliable approaches. Multi-stage builds make it straightforward: build in a fully-featured image, copy only the resulting binary, certs, or runtime files into a tiny final image. The trade-offs:

Architectures like Kubernetes offer init-containers, which let you perform ephemeral setup with a fuller image while keeping the main container distroless. That’s a practical compromise that retains security while preserving operational flexibility. (docker.com)

Practical caveats

Putting it together — what you get

Combine these techniques and the benefits stack:

Think of it like arranging a band’s tour: pack only the instruments you’ll actually play on stage, keep backups on a shared drive, and make sure the roadies (CI) know where to find the gear without unpacking the whole truck each time.

Final example: a minimal Buildx pipeline snippet

Here’s a compact example of what a GitHub Actions step could look like using Buildx with an OCI registry cache:

- name: Set up QEMU and Buildx
  uses: docker/setup-buildx-action@v4

- name: Login to registry
  uses: docker/login-action@v4
  with:
    registry: ghcr.io
    username: $
    password: $

- name: Build and push with cache
  uses: docker/build-push-action@v7
  with:
    push: true
    tags: ghcr.io/myorg/myapp:latest
    cache-from: type=registry,ref=ghcr.io/myorg/myapp:buildcache
    cache-to: type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max

This setup tells Buildx to pull cache data from ghcr.io/myorg/myapp:buildcache and to push updated cache back to the same reference, helping subsequent builds skip repeated work. (docs.docker.com)


The modern Docker toolchain rewards a little upfront thinking in your Dockerfile and CI pipeline. By ordering layers intelligently, using cache mounts, and exporting caches across builders, teams can make builds feel snappier and images leaner—more like a tight four-piece band than a stadium ensemble.