Speed Up Docker Builds in GitHub Actions: A Cache Guide
Speed up Docker builds in GitHub Actions: export layers with cache-to type=gha, know when a cache mount fails, and restore tool caches with actions/cache.
By Matthews WongReviewed on
Key takeaway
A Docker build in GitHub Actions is slow by default because every job starts on a new virtual machine with nothing cached. You speed it up by exporting the layer cache with cache-to type=gha and importing it with cache-from, ordering the Dockerfile so slow steps stay unchanged, and restoring tool caches such as Next.js's with actions/cache.
You will be able to
- Explain why a fresh runner rebuilds everything, and which cache fixes which part.
- Configure cache-from and cache-to type=gha, and say why a multi-stage build needs mode=max.
- Write an actions/cache key that keeps saving, and know the size, age and branch limits.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
How a cold runner gets its cache back
A fresh runner starts empty. cache-from restores the layers, a changed source reruns only the layers after it, actions/cache restores Next's compile cache, and cache-to saves everything back to GitHub's store.
Keys: ← → step · Home / End · K play or pause
Top, one build job on a runner: two layers, next build and Next's own cache folder. Bottom, GitHub's cache store, the only thing that outlives the runner.
Every hosted job runs on a new virtual machine. Nothing from the last build is on its disk, so every layer and every page would be built from scratch.
Predict the next step What puts the npm ci layer back on the empty runner? (cache-from: type=gha · git checkout · Docker keeps it by itself)cache-from imports the saved layers from GitHub's store. The npm ci layer is reused instead of rebuilt, because its inputs did not change.
cache-from: type=ghaCOPY . . sees changed files, so it runs again, and so does every layer after it. The npm ci layer above it stays reused.
Predict the next step next build must run again. Does it have to compile every page from scratch? (Yes, always · No, a restored Next cache lets it resume)Layers cannot hold files that live outside them. actions/cache restores .next/cache onto the runner, and the build context carries it into next build.
uses: actions/cache@v4 path: .next/cacheWith mode=max, cache-to exports every layer, not only the ones in the shipped image. A successful job also saves a new actions/cache entry.
cache-to: type=gha,mode=maxGitHub keeps 10 GB per repository by default and removes the least recently used entries first, plus any unused for over 7 days.
Why is a Docker build slow on a fresh GitHub Actions runner?
On your laptop, Docker keeps the layers of the last build, so a rebuild skips every step whose inputs did not change. A GitHub-hosted runner does not have that luxury: GitHub's docs describe each hosted runner as a new virtual machine. The job ends, the machine goes away, and the next job starts with an empty disk and an empty layer cache.
Read more (2)Show less
So the cache has to live somewhere that outlives the runner, and Docker lists four places: inline (inside the image), registry (a separate image), local (a folder) and gha (GitHub's own cache service). Inside a GitHub Actions workflow, Docker's docs call gha the recommended choice, and that is what this site uses.
Whatever the store, the reuse rules come from Docker, and they are strict. A RUN is matched on its command string. COPY and ADD are matched on a checksum of file metadata, not on modification time. And once one layer is invalidated, every instruction after it runs again, even if its own inputs are identical. That is why the Dockerfile copies package.json and the lockfile first, runs npm ci, and only then copies the rest of the source.
How do cache-from and cache-to type=gha work?
cache-from imports a cache at the start of the build and cache-to exports one at the end. With docker/build-push-action the two lines are all you write, and the URL and token the gha backend needs are filled in for you. That is also why the backend only works inside a GitHub Actions workflow: those values exist nowhere else.
Read more (2)Show less
The parameter that matters most is mode, and it only applies to cache-to. In min mode, the default, only layers that are exported into the resulting image are cached. In max mode, all layers are. This site's runner stage copies in only the built output, so the deps and builder stages never end up in the shipped image, and in min mode their layers, npm ci and next build included, would never be saved. mode=max is what makes them reusable.
Two more parameters are worth knowing. scope names the cache object and defaults to buildkit, and builds that share a scope overwrite each other's cache, so give each image its own scope if one repository builds several. And access follows branches: a workflow can read only the cache of the current branch, the base branch and the default branch.
Which cache fixes which problem: layers, cache mounts or actions/cache?
A layer cache lets whole instructions be skipped when their inputs are unchanged. A cache mount, RUN --mount=type=cache, is different: Docker describes it as a persistent cache location used during builds, cumulative across builds, so when a layer must re-run, npm only downloads new or changed packages. Docker's own CI page adds the catch: BuildKit does not keep cache mounts in the GitHub Actions cache by default. On a cold runner you should not count on the mount being full.
Read more (2)Show less
That gap is why this site uses a third cache for the one thing layers cannot hold. next build is only incremental when the .next/cache folder from the last build is present, so the workflow restores it with actions/cache and a negation line in .dockerignore lets the folder ride into the build context. The comments in the workflow and in .dockerignore record that a cache mount is the obvious alternative and does not work here, for exactly the reason above.
actions/cache matches in a fixed order: an exact key first, then each restore-keys entry in sequence, and among several partial matches it returns the most recently created one. A cache's contents cannot be changed, and an exact key hit creates no new entry. So the key here ends in the commit SHA: it never hits exactly, every run saves a fresh entry, and restore-keys fall back to the newest cache for the same lockfile.
How big can the cache get, and who can write to it?
GitHub's limit is 10 GB of caches per repository by default. When it is full, the oldest entries by last access are deleted first, and any entry not accessed for over 7 days is removed. Docker says the gha backend should be used only within the limits GitHub sets, and its cache API can be rate-limited, which shows up as a maximum timeout reached error during export.
Read more (2)Show less
A cache that is restored, grown and saved on every run only ever grows, so this workflow adds its own ceiling: past 1,500 MB the Next.js cache is not saved, and the next build starts cold once and small again. The cache is also saved only when the job completes successfully, so a failing build never poisons it with half-finished work.
Writing is the sensitive half. Docker's docs warn that some events get read-only cache access, where an export fails with failed to reserve cache, and that giving untrusted workflows write access raises the risk of cache poisoning. The safe shape is to keep cache-from, drop cache-to for those events, and let a push to the default branch fill the cache.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# Cache npm's download directory, not node_modules. This survives within a build
# even when the layer above is invalidated, so a lockfile change re-links from
# cache instead of re-downloading every tarball. Still `npm ci`, so the lockfile
# stays authoritative.
RUN --mount=type=cache,target=/root/.npm npm ciThe order does the first half of the work: package files, then npm ci, with the full source copied later in the builder stage. The cache mount points at npm's download folder, so when a lockfile change forces npm ci to re-run, packages already in the mount are not fetched again. On a cold runner that mount starts empty, which is why it is a bonus and not the plan.
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.meta.outputs.ref }}:latest
${{ steps.meta.outputs.ref }}:${{ github.sha }}
cache-from: type=ghaThe whole layer-cache setup is the last two lines. cache-from imports, cache-to exports, and mode=max is there because the deps and builder stages are not in the shipped image. The tags above it are unrelated to the cache: a cache hit changes how fast the image is built, never what is in it.
- name: Restore Next.js build cache
uses: actions/cache@v4
with:
path: .next/cache
# SHA in the key so every run writes a fresh entry; restore-keys then
# falls back to the most recent cache for the same lockfile, and
# finally to any cache at all, rather than starting cold.
key: next-cache-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: |
next-cache-${{ hashFiles('package-lock.json') }}-
next-cache-A key that ends in the commit SHA is new on every run, so the post-job step always has something to save. restore-keys are prefixes tried in order: the newest cache for this exact lockfile wins, and any Next.js cache at all beats starting cold.
.next
!.next/cacheTwo lines carry a lot. .next is excluded from the build context, then the negation brings back only .next/cache. Docker's rule is that the last matching line decides, so the exception must come after the exclusion it overrides.
# The cache is restored, grown and saved again on every run, so
# nothing ever shrinks it. Past the cap, save nothing: the next
# build starts cold once, and the cache starts small again instead
# of growing until the runner's disk runs out.
size_mb="$(du -sm .next/cache | cut -f1)"
if [ "$size_mb" -gt "$NEXT_CACHE_CAP_MB" ]; then
echo "::warning title=Next.js cache over cap::.next/cache is ${size_mb} MB (cap ${NEXT_CACHE_CAP_MB} MB), so this run saves none and the next build starts cold."
rm -rf .next/cache
fiThe ceiling on the Next.js cache. Past the cap the step deletes the folder, so actions/cache skips the save with a warning and the next build starts cold once. A small, occasionally cold cache beats one that grows until the runner's disk fills.
Mistakes that cost real time
mode=min on a multi-stage build
cache-to with the default mode caches only layers that end up in the final image. In a multi-stage build the expensive layers, the install and the compile, live in stages that never ship, so they are rebuilt on every run while the log shows a cache being written. Set mode=max.
A cache mount you expected to survive
BuildKit does not keep cache mounts in the GitHub Actions cache by default, so a mount that is fast on your laptop starts empty on a fresh runner. Restore the folder you care about with actions/cache, as this site does for .next/cache, or use the cache-dance action Docker's docs mention.
A fixed actions/cache key that never updates
With key: next-cache-v1 the first run saves, and every later run hits the exact key, which creates no new entry. The cache silently freezes at its first contents. Add the commit SHA to the key and list a prefix in restore-keys so each run saves a fresh entry.
An optional cache export that blocks the deploy
On 2026-10-03 this site's cache-extraction step lost its layers, re-ran npm ci and the whole build, then died with EOF. The job failed and the deploy of a correctly pushed image was skipped. Steps that only speed things up and run after the push get continue-on-error.
Writing the cache from an event that may not
Some events get read-only cache access, and an export there fails with failed to reserve cache. Do not widen write access to fix it, because untrusted workflows with write access can poison the cache. Keep cache-from, remove cache-to for that event, and fill the cache from a push to the default branch.
| Layer cache (type=gha) | Cache mount | actions/cache | |
|---|---|---|---|
| What it stores | Layers, chosen by mode | A folder used during one RUN | Any folder you name |
| Survives a fresh runner | Yes, through cache-from and cache-to | Not by default | Yes, through key and restore-keys |
| Declared in | The build step | The Dockerfile | The workflow |
| Best for | Skipping whole unchanged steps | Re-downloads when a step must re-run | Tool caches outside the layers, such as .next/cache |
Do it on your own machine
You need a GitHub repository with a small Node project that builds to a dist folder (the project from the multi-stage lesson works). You will add a Dockerfile and a workflow, then read the run times to see the cache work.
Add a Dockerfile with a deps stage, a builder stage, and a final stage that copies only the built output.
# syntax=docker/dockerfile:1 FROM node:22-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:22-alpine WORKDIR /app COPY --from=builder /app/dist ./dist CMD ["node", "dist/server.js"]Add .github/workflows/build.yml. It builds on every push and uses the gha cache with mode=max. push is false, so nothing is published.
name: build on: push jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: context: . push: false cache-from: type=gha cache-to: type=gha,mode=maxPush once and note how long the build step took. Then change one line of source, push again, and compare. The install layer should now be reused, so the second run is shorter.
Change cache-to to type=gha with no mode, push a source change, and watch the install and compile steps run again. That is the min-mode trap from this lesson. Put mode=max back afterwards.
cache-to: type=gha
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.Why does a Docker build that is fast on your laptop start slow on a hosted runner?Show the answer
Answer:Each hosted runner is a new virtual machine, so the layers from the last build are gone. The cache has to be exported somewhere that outlives the runner, such as the gha backend.
2.Why does this workflow use actions/cache for .next/cache instead of a cache mount?Show the answer
Answer:BuildKit does not keep cache mounts in the GitHub Actions cache by default, and cache-to exports layers, not mount contents. actions/cache restores the folder onto the runner, and the build context carries it in.
3.Does a failed job save its actions/cache entry?Show the answer
Answer:No. A new cache is created provided the job completes successfully. That keeps half-finished work out of the cache.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.