Why Does a Base Image Update Invalidate My Cache?
Every layer after FROM is chained to the base image, so a new base digest invalidates all of them. Pin the digest and schedule the rebuild on your terms.
A Docker build cache is a chain, and the bottom link is the base image that FROM resolves to. When the base image content changes, the FROM layer gets a new cache key, every instruction stacked above it inherits a different parent, and the rest of the Dockerfile rebuilds from scratch.
Answer
BuildKit keys each layer on its parent layer plus the instruction that produced it, and it documents the consequence directly: once one layer is invalidated, all downstream layers are invalidated too, and for FROM the trigger is a change in the base image itself, per the Docker cache invalidation reference, checked on 2026-08-13. A base image update is the worst case for that rule, because FROM sits at position one.
The part that surprises people is that nothing in the repository has to change for this to fire. FROM ubuntu:24.04 is a tag, and a tag is a mutable pointer that the registry can repoint at a new digest whenever the publisher pushes a rebuild. The Dockerfile line reads the same in git log while the content it resolves to is different, per the Dockerfile FROM reference, checked on 2026-08-13.
Here is the same Dockerfile across an upstream tag repoint:
Build on Monday ubuntu:24.04 -> sha256:aaaa
[1] FROM ubuntu:24.04 key: sha256:aaaa HIT
[2] RUN apt-get install -y libvips key: [1] + command HIT
[3] COPY package-lock.json . key: [2] + file digest HIT
[4] RUN npm ci key: [3] + command HIT
[5] COPY . . key: [4] + context HIT
Build on Tuesday ubuntu:24.04 -> sha256:bbbb
[1] FROM ubuntu:24.04 key: sha256:bbbb MISS <- chain root moved
[2] RUN apt-get install -y libvips parent [1] changed MISS
[3] COPY package-lock.json . parent [2] changed MISS
[4] RUN npm ci parent [3] changed MISS
[5] COPY . . parent [4] changed MISSSteps 2 through 5 are byte-identical between the two builds. They rebuild anyway, because a layer's identity includes the identity of everything underneath it.
Two things do survive the change:
| Element | What its cache key includes | After a base digest change |
|---|---|---|
FROM node:22@sha256:... | The resolved base image digest | Rebuilt |
RUN apt-get install -y libvips | Parent layer plus the command string | Rebuilt |
COPY package-lock.json . | Parent layer plus the checksum of the copied files | Rebuilt |
RUN --mount=type=cache,target=/root/.npm npm ci | Parent layer plus the command; the mounted directory lives outside the layer chain | Layer rebuilt, mounted directory reused |
A second stage whose own FROM did not move | That stage's base digest plus its own instructions | Reused, until it copies from a rebuilt stage |
Cache mounts are the useful exception. A directory mounted with RUN --mount=type=cache is persisted by the builder between builds and is never part of a layer, per the Dockerfile cache mount reference, checked on 2026-08-13, so a package manager download cache or a compiler cache comes back warm even when every layer above the base is being rebuilt. The catch is that the mount lives on the builder, so it survives only where the builder survives.
Multi-stage Dockerfiles are the other place where the blast radius is smaller than it looks. Each stage has its own FROM and its own chain, described in the multi-stage build reference, checked on 2026-08-13. Bumping a slim runtime base rebuilds the runtime stage and leaves an expensive compile stage untouched.
Detail
Pin the digest so the invalidation happens when you decide
FROM accepts a digest, and a digest addresses immutable content:
FROM ubuntu:24.04@sha256:3d1556a8a18cf5307b121e0a98e93f1ddf1f3f8e092f1fddfd941254785b95d7The tag stays in the line as a readable label and the digest is what the builder resolves. Upstream can repoint ubuntu:24.04 as often as it wants; your build keeps hitting the same chain root until a commit changes the digest. Image digest covers what that string addresses and why it is stable.
Read the current digest for a tag with buildx, using the imagetools inspect reference, checked on 2026-08-13:
docker buildx imagetools inspect ubuntu:24.04 --format '{{.Manifest.Digest}}'For a multi-architecture tag that returns the digest of the index manifest, which is the right thing to pin: it resolves to the correct per-platform manifest on both amd64 and arm64 builds.
A scheduled workflow that opens the bump pull request
Pinning without a refresh schedule freezes the base image, including its unpatched packages. The pairing that works is a digest pin plus a job on a cron that resolves the tag, rewrites the FROM line, and opens a pull request. Scheduled workflows use the schedule event, documented in the GitHub Actions events reference, checked on 2026-08-13.
name: base-image-update
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
bump-base-image:
runs-on: warp-ubuntu-latest-x64-2x
steps:
- uses: actions/checkout@v4
- name: Resolve the published digest for ubuntu:24.04
id: resolve
run: |
digest=$(docker buildx imagetools inspect ubuntu:24.04 \
--format '{{.Manifest.Digest}}')
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: Rewrite the FROM line
run: |
sed -i -E \
"s|^FROM ubuntu:24\.04@sha256:[0-9a-f]{64}|FROM ubuntu:24.04@${{ steps.resolve.outputs.digest }}|" \
Dockerfile
git diff --stat -- Dockerfile
- name: Open a pull request
uses: peter-evans/create-pull-request@v7
with:
branch: chore/base-image-ubuntu-2404
commit-message: "chore: bump ubuntu:24.04 to ${{ steps.resolve.outputs.digest }}"
title: "Bump ubuntu:24.04 base image"
body: |
Scheduled base image refresh.
Merging this rebuilds every layer above FROM. That is the intended
trade: the rebuild is paid here, on one reviewed pull request, rather
than on whichever feature branch happened to build first after
upstream repointed the tag.The action opens no pull request when the digest is unchanged, per the create-pull-request repository, so a quiet week produces a no-op run at the 2 vCPU rate. Run the same job for every base image the repository pins, including the ones inside multi-stage builds, and give each one its own branch name so the pull requests stay reviewable on their own.
What the two approaches cost
The rebuild itself is fixed work. The variable is how many separate cache scopes pay for it. Assumptions here are stated so you can substitute your own from a job log: 600 Docker builds a month, a warm build at 4 minutes and a full cold rebuild at 22 minutes, upstream repointing the tag 4 times in the month, and roughly 7 active branch cache scopes on a busy repository. The runner is warp-ubuntu-latest-x64-16x at $0.032 per minute from the pricing page, checked on 2026-08-13.
| Line | Unpinned tag | Digest pinned, weekly bump |
|---|---|---|
| Full cold rebuilds per month | 28 | 4 |
| Extra runner minutes | 504 | 72 |
| Extra runner cost per month | $16.13 | $2.30 |
| Where the rebuild lands | On whichever pull requests build first after each repoint | On the bump pull request |
| Who waits 22 minutes | Whoever opened those pull requests | The reviewer of one scheduled pull request |
The dollars are small and the scheduling is the point. Twenty-eight surprise 22-minute builds spread across a month land on people who changed one line of application code and have no idea why their build went cold.
The branch-scope multiplier disappears when the layer cache stops being per branch. A WarpBuild remote Docker builder profile maps to a dedicated builder virtual machine that keeps its layer cache on local disk and shares it across every job pointed at that profile, per the Docker builders documentation, checked on 2026-08-13. One build repopulates the chain after a base change and the jobs behind it read the warm layers, so the cold rebuild count tracks the number of digest changes rather than the number of branches. That builder cache has a 10 day TTL, so a profile left unused for more than 10 days is reset and the next build is cold for the same reason a base bump is. Builder sizes start at 16 vCPU, 32 GB, and 100 GB of disk at $0.06 per minute on the pricing page, checked on 2026-08-13, and the builder is billed separately from the runner. For dependency caching that has nothing to do with Docker layers, the WarpBuild caching documentation covers the drop-in replacement for actions/cache.
Linux runners are the usual host for a Docker build job. The Docker builds on GitHub Actions hub covers the rest of the pipeline.
Related Questions
Does pinning by digest stop base image security updates from reaching my image?
Pinning freezes the base image until a commit changes the digest, so the update arrives on the scheduled bump rather than on the next build. That is why the pin and the cron ship as one change: the pin decides that invalidation happens on a reviewable commit, and the schedule decides that it happens every week. The base image update workflow guide covers the full job, including repositories that pin several bases, and image digest defines what the pinned string addresses.
Why did only some of my build stages rebuild?
Each stage in a multi-stage Dockerfile carries its own FROM and its own cache chain, per the multi-stage build reference. Bumping the runtime stage base rebuilds the runtime stage and leaves a cached builder stage alone. Bumping the builder stage base rebuilds that stage plus every later stage that copies from it with COPY --from. The Docker cache invalidation guide works through the ordering rules that decide which stages are affected.
Can I avoid the rebuild after a base image change entirely?
No. New base content produces new layer content for everything above it, so the work happens once by definition, per the Docker cache invalidation reference. What you control is the timing and the repeat count. A digest pin plus a scheduled bump moves the rebuild onto one pull request, and a builder that holds its layer cache on local disk means one job repopulates the chain for every job behind it, per the Docker builders documentation. The Docker builds on GitHub Actions hub puts both moves in the context of a full pipeline.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.