Skip to main content
DevOps from zero · Lesson 9 of 15 · 13 min

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.
Watch it work

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.

Step 1 of 7. 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.

Keys: ← → step · Home / End · K play or pause

  1. 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.

  2. 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)
  3. 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=gha
  4. COPY . . 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)
  5. 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/cache
  6. With 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=max
  7. GitHub 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)

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)

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)

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)

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.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

Dockerfile:4-11
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 ci

The 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.

.github/workflows/deploy.yml:120-128
      - 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=gha

The 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.

.github/workflows/deploy.yml:87-97
      - 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.

.dockerignore:20-21
.next
!.next/cache

Two 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.

.github/workflows/deploy.yml:175-183
          # 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
          fi

The 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.

What breaks

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.

Three caches in one build, and what each one is for
What it storesLayers, chosen by modeA folder used during one RUNAny folder you name
Survives a fresh runnerYes, through cache-from and cache-toNot by defaultYes, through key and restore-keys
Declared inThe build stepThe DockerfileThe workflow
Best forSkipping whole unchanged stepsRe-downloads when a step must re-runTool caches outside the layers, such as .next/cache
Try it yourself

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.

  1. 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"]
  2. 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=max
  3. Push 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.

  4. 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
Quick quiz

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.

  1. 1.A multi-stage build exports its cache with cache-to type=gha and no mode. The npm ci and next build layers still rebuild on every run. Why?
  2. 2.A workflow restores .next/cache with the fixed key next-cache-v1. After the first run the cache never improves. What is happening?
  3. 3.An event with read-only cache access fails at export with failed to reserve cache. What is the safest fix?
Think it through

Answer in your head, then check

  1. 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. 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. 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.

Go deeper

Related deep dives on this site