on
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:
- Slow CI rebuilds that reinstall dependencies every commit.
- Bloated runtime images that increase attack surface and startup time.
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
- Order your Dockerfile so stable steps (installing deps) happen before frequently changing steps (copying source).
- Use BuildKit cache mounts so package managers reuse downloaded bits across builds.
- Export/import build cache to a registry (or external store) so CI builders can pick up previous work.
- Use multi-stage builds to keep runtime images minimal—optionally distroless or scratch—to reduce attack surface and image size. (docs.docker.com)
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:
- The first stage prepares dependencies and uses RUN –mount=type=cache to persist package cache across builds, which reduces repeated downloads. The cache is isolated from the final image and speeds rebuilds. (docs.docker.com)
- Multi-stage builds let you copy only artifacts into the final image so build-time tooling never ships with the runtime. (docker.com)
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:
-
Export inline cache into the image (cache metadata embedded into the image): docker buildx build –push -t myuser/myapp:latest –cache-to type=inline .
-
Use a registry-backed cache image: docker buildx build –push -t myuser/myapp:latest –cache-to type=registry,ref=myuser/myapp:buildcache –cache-from type=registry,ref=myuser/myapp:buildcache .
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:
- Pros: smaller images, fewer packages to maintain, smaller attack surface.
- Cons: reduced ability to debug inside the container (no shell), and sometimes missing init-time utilities that users expect.
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
- Multi-platform builds and caching: Build caches can be platform-specific. When doing multi-arch builds, be aware that a single cache image might not contain caches for every platform—some extra coordination or separate caches per arch may be necessary. (docs.docker.com)
- Cache invalidation: Changing files that a layer depends on will invalidate that layer and downstream steps. Small changes to package manifests (e.g., package.json) are safer to keep isolated in earlier layers so the heavy steps remain cached. (docs.docker.com)
- Secrets and private data: Don’t bake secrets into images or caches. BuildKit supports secrets and private mounts to keep credentials out of image layers and logs. (docs.docker.com)
Putting it together — what you get
Combine these techniques and the benefits stack:
- CI builds that fetch dependencies once and reuse them across runs.
- Final images that contain only what they need to run.
- Smaller attack surface through distroless/runtime-only images where appropriate.
- Faster developer feedback loops and less time waiting on redeploys.
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.