ACE Journal

Container Image Layer Caching and Build Optimization

Abstract

Container image builds are often the longest-running step in a CI pipeline, and most of that time is spent re-downloading or recomputing layers that have not changed since the previous build. A well-structured Dockerfile combined with a remote cache backend can reduce image build times from several minutes to under 30 seconds for a warm build, without changing the resulting image. The techniques involved are not new, but their consistent application remains uncommon, and BuildKit’s cache export features have made them more accessible than they were two years ago.

Layer Ordering Fundamentals

Docker’s layer cache invalidates every layer below the point where a file changes. The most common mistake is copying the entire source tree early in the Dockerfile, before installing dependencies, which means that any source file change also invalidates the dependency installation layer and triggers a full npm install, pip install, or go mod download from scratch. The correct ordering is to copy only the dependency manifest first (for example package.json and package-lock.json), run the install, then copy the full source. This way, the dependency layer is only rebuilt when the manifest changes. For Go projects, copying go.mod and go.sum and running go mod download before copying any .go source files achieves the same effect.

BuildKit Remote Cache Backends

BuildKit, which has been the default builder backend since Docker 23, supports exporting cache manifests to an OCI registry via --cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max. Subsequent builds import that cache with --cache-from type=registry,ref=.... In mode=max, BuildKit exports the cache for every intermediate layer, not just the final image layers, which dramatically improves cache hit rates for multi-stage builds. In GitHub Actions, this integrates cleanly with the docker/build-push-action action, which accepts cache-from and cache-to parameters directly. The main operational cost is registry storage for the cache manifest, which typically runs 20 to 50 percent of the image size and can be pruned on a schedule using registry garbage collection.

Multi-Stage Builds and Slim Final Images

Multi-stage builds serve two purposes: they keep build tools out of the final image, and they create natural cache boundaries. A Go service build that compiles in a golang:1.23 stage and copies only the binary into a gcr.io/distroless/static-debian12 final stage produces a final image under 20 MB with no shell, no package manager, and a minimal attack surface. The compiler, test runner, and any intermediate artifacts never appear in the published image. Caching the compilation stage separately from the final stage means that changes to the Dockerfile’s runtime configuration (environment variables, labels, entrypoint) do not invalidate the expensive compilation layer.

Measuring What You Gained

Build time alone is an incomplete metric because it does not distinguish between cache miss time (downloading layers from the registry) and actual computation time (running the compiler or package manager). BuildKit’s --progress=plain output breaks down timing per layer, and GitHub Actions’ timing summaries show the total build step duration across runs. A useful baseline is to measure the cold build time (no cache), the warm build time (full cache hit), and the partial-cache build time (source changed, dependencies unchanged) before and after restructuring the Dockerfile. Most teams find that the dependency install layer is the dominant cost, and that correct layer ordering alone, without any remote cache configuration, cuts warm build times by 50 to 70 percent.