on
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)
- Faster iterative builds = less developer waiting and cheaper CI minutes.
- Smaller images = faster pulls, faster scaling, and smaller attack surface.
- Predictable caching = consistent builds across machines and CI.
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)
- Push images and cache to a registry while building multi-platform images:
docker buildx build \ --platform linux/amd64,linux/arm64 \ --cache-to type=registry,ref=registry.example.com/org/repo:buildcache,mode=max \ --cache-from type=registry,ref=registry.example.com/org/repo:buildcache \ --push -t registry.example.com/org/app:latest .Notes:
- type=registry cache exports caches to an OCI registry image; mode=max stores more data for faster restores.
- You can also export cache to GitHub Actions cache if building within GitHub Actions using the
ghabackend. (docs.docker.com)
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?
- Scratch: best when you have a statically compiled binary and no runtime OS deps.
- Distroless: good when you need a tiny, secure runtime but still rely on dynamic libraries or CA certs.
- Slim: useful when you need a package manager at runtime or debugging tools available. Choosing a minimal-but-appropriate base reduces size and attack surface. Guides and comparisons across these variants are widely used in production decisions. (dockerbuild.com)
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:
- COPY package.json and run npm install
- COPY rest of source This way dependency installation is cached unless package.json changes. Use .dockerignore to avoid copying editor metadata, node_modules, build artifacts, etc., into the build context — this reduces cache churn and context upload time.
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:
- For Node: prefer npm ci + package-lock.json (reproducible installs).
- For Python: use pinned requirements/constraints files and install with pip wheel caches in the same layer then delete build wheels if not needed.
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
- Cache misses from changing image pins: pin base image digests for reproducibility (use @sha256:…). If a base image changes unexpectedly you may re-download and rebuild dependencies.
- Export/import slowness: some cache exporters (type=local, type=registry) can be slower depending on your network and CI runner I/O. Experiment and measure.
- Multi-repo shared caches: be careful if different projects share the same cache ref; cache poisoning or unexpected cache invalidation can occur. Prefer per-repo or per-team cache refs.
Real-world checklist (quick)
- Enable BuildKit / buildx in your CI.
- Add a .dockerignore that excludes node_modules, .git, tmp files.
- Reorder Dockerfile: install deps before copying full source.
- Use multi-stage builds and copy only runtime artifacts into final image.
- Choose scratch/distroless for statically compiled or minimal runtimes; use slim when you need debugging tools or package managers.
- Export cache to a registry or CI cache backend (or use inline cache) so subsequent runs reuse dependency layers.
- Pin base image versions (prefer digests) for stable builds.
Why this approach saves money and time
- Less rebuild work in CI lowers minutes billed.
- Smaller images reduce network transfer time in autoscaling events and cluster rollouts.
- A tiny runtime image has fewer components to scan for vulnerabilities and less surface for attackers.
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
- Optimize cache usage in builds (Docker Docs) — how to use –cache-to / –cache-from and cache strategies. (docs.docker.com)
- Building best practices (Docker Docs) — multi-stage builds, pinning images, and other Dockerfile recommendations. (docs.docker.com)
- Choosing a base image: Alpine, Slim, Distroless, Scratch — practical comparisons. (dockerbuild.com)
- GitHub Actions cache backend for buildx — how the gha cache backend integrates with buildx in CI. (docs.docker.com)
- Mastering Docker Cache (tutorial) — examples for inline cache and common caching patterns. (dockerbuild.com)
Happy building — keep your builds cached, your images minimal, and your deploys fast.