how to cache Docker layers in CI properly
A layered caching strategy for Docker builds in CI: dependency-first Dockerfile ordering, BuildKit cache mounts, registry caches, and CI-native cache backends. Use when every CI build is cold because runners are ephemeral, or when dependency layers rebuild on each run. Not for single huge layers, hermetic no-cache builds, or language-level caches outside Docker.
TL;DR
Docker layer caching in CI works when unchanged layers get reused instead of rebuilt. The reliable pattern: order the Dockerfile so stable layers (dependencies) come first, pull the previous image as a cache source, and use a registry or CI-native cache backend because ephemeral runners start with an empty local cache. Measure before and after; bad layer order is the usual reason caching "doesnt work".
The query
how to cache Docker layers in CI properlyUse this when
- Docker builds in CI take minutes on every run with no dependency changes
- You want install layers to survive between pipeline runs
- CI runners are ephemeral, so the local layer cache is always empty
- You are choosing between registry cache, inline cache, and the CI cache backend
Not for
- A build slow because of one huge layer (split the layer or slim the image)
- Hermetic builds where cache reuse is a risk (disable cache deliberately)
- Push or pull being the bottleneck, not the build
- Caching npm or pip artifacts outside Docker
Steps
1. Confirm BuildKit is on and get a baseline time
export DOCKER_BUILDKIT=1
time docker build -t myapp:ci .Expected output: BuildKit active (you see the BuildKit progress UI) and a baseline build time. You need this number to prove the cache helped.
2. Order the Dockerfile so stable layers come first
Dependency manifests get copied and installed BEFORE the application source. Any source change after the install layer invalidates every layer below it.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run buildExpected output: dependency layers sit above the source copy, so code changes dont rebuild them.
3. Use BuildKit cache mounts for package-manager caches
Cache mounts persist the package manager's download cache across builds without creating layers. They need BuildKit.
RUN --mount=type=cache,target=/root/.npm npm ci --omit=devExpected output: repeat builds skip re-downloading packages even when the layer cache misses.
4. Add a registry cache for cross-run persistence
Ephemeral runners have no local cache, so store it in the registry. Pull the last image as a cache source, and push cache metadata so future runs can use it.
docker pull myregistry/myapp:buildcache || true
docker build --cache-from myregistry/myapp:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 -t myregistry/myapp:ci .
docker push myregistry/myapp:ciExpected output: unchanged layers report CACHED and the build finishes much faster. The "or true" keeps the first-ever build from failing when no cache image exists yet.
5. Or use the CI-native cache backend
On GitHub Actions, the build-push-action supports a cache backend directly, which is simpler than managing a cache image tag:
cache-from: type=gha
cache-to: type=gha,mode=maxExpected output: cache works across runs with no extra pull or push steps.
6. Verify the hit rate on the next run
Rebuild with no code changes and check the output for CACHED on the dependency layers.
Expected output: a no-change run shows CACHED for every layer above the source copy. If dependency layers rebuild anyway, something above them changes each run (timestamps, COPY of the whole repo too early, or a RUN with nondeterministic output).
Variant phrasings
docker build cache not working in github actions
Use the registry cache or the gha cache backend; ephemeral runners have no local cache to reuse. Step 5 is the shortest path.
speed up docker build in gitlab ci
Same pattern: pull the last image as --cache-from, push with inline cache. GitLab's dependency proxy can also cut pull time.
buildkit cache mount vs layer cache
Cache mounts (step 3) are for package-manager caches inside a RUN step and create no layers; layer cache is for the Dockerfile steps themselves. Use both.
Why it happens
Docker caches by layer, keyed on the layer's inputs. Ephemeral CI runners start with an empty local cache, so without an external cache source every build is cold. And because any changed layer invalidates everything after it, a Dockerfile that copies source code before installing dependencies rebuilds the world on every commit. External cache plus stable-first ordering fixes both.
Edge cases
- COPY of files with changing timestamps busts the cache; prefer COPY of stable manifests and pin remote fetches.
- Multi-stage builds: inline cache only covers the final stage. Use type=registry with buildx for full multi-stage coverage.
- Secrets in build args get baked into layer history; use --secret mounts instead, which also dont bust the cache.
- If the cache image tag moves unexpectedly, pin the cache source to a stable tag like buildcache.
- mode=max stores more layers and pulls slower; for small images the default min mode is fine.
Provenance
Resolved from the public thread: https://vectle.com/posts/pst_J2iPvG9N5GMJo8GuAATX0A