Tutorial8 min read

How to Convert CircleCI Caches to GitHub Actions Without Rebuilding Everything

CircleCI's restore_cache + save_cache pair maps to a single actions/cache@v4 step in GitHub Actions — but the syntax, the key semantics, and the gotchas all differ. The four patterns that cover 90% of real pipelines, plus the gotchas nobody warns you about.

CircleCI and GitHub Actions both cache things, but they disagree about almost every detail of how. CircleCI uses an explicit pair of restore_cache and save_cache steps with multiple key candidates and a checksum syntax that doesn't exist anywhere else. GitHub Actions uses a single actions/cache@v4 step with a primary key and an array of fallback restore-keys. Translating between them is more involved than it looks, and getting it wrong silently slows every build by 30 seconds to 2 minutes.

This is a focused tutorial on doing that translation correctly — the syntax, the gotchas, and the four common patterns that cover 90% of real pipelines.

The Core Difference in One Diagram

CircleCI                          GitHub Actions
─────────                         ──────────────
- restore_cache:                  - uses: actions/cache@v4
    keys:                           with:
      - v1-deps-{{...}}-{{...}}      key: v1-deps-${{...}}-${{...}}
      - v1-deps-{{...}}              restore-keys: |
      - v1-deps-                       v1-deps-${{...}}
                                       v1-deps-
- run: npm ci

- save_cache:
    key: v1-deps-{{...}}-{{...}}
    paths:
      - node_modules                  path: node_modules

CircleCI splits the operation across two explicit steps, which means you can put work between restore and save. GitHub does it as a single step with a post-action that runs at the end of the job — you don't see the save explicitly, it just happens.

The mental model translation: CircleCI's keys: array becomes GitHub's key: (the first entry) plus restore-keys: (the rest). The paths: list under CircleCI's save_cache becomes the GitHub path: field. CircleCI accepts multiple paths; GitHub accepts a multi-line string.

Translation Pattern #1: Single-Path Cache

The most common pattern — caching a single directory like node_modules.

CircleCI:

- restore_cache:
    keys:
      - npm-deps-{{ checksum "package-lock.json" }}
      - npm-deps-
- run: npm ci
- save_cache:
    key: npm-deps-{{ checksum "package-lock.json" }}
    paths:
      - node_modules

GitHub Actions:

- uses: actions/cache@v4
  with:
    path: node_modules
    key: npm-deps-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      npm-deps-
- run: npm ci

Two transformations:

  1. {{ checksum "..." }}${{ hashFiles('...') }}. The CircleCI checksum function reads the file and hashes its contents. GitHub's hashFiles() does the same thing but accepts globs (hashFiles('**/package-lock.json') is valid; the CircleCI version is single-file only).
  2. The keys: array splits — first entry becomes key:, rest become restore-keys:. The order matters in both formats: most-specific first, most-general last.

The GitHub restore-keys: block uses a YAML pipe (|) for multi-line strings, with each key on its own line. Don't put dashes in front of them — those would create a YAML list, which fails.

Translation Pattern #2: Multi-Path Cache

CircleCI lets paths: contain multiple directories. GitHub accepts the same via a multi-line path: string.

CircleCI:

- save_cache:
    key: gradle-{{ checksum "build.gradle.kts" }}
    paths:
      - ~/.gradle/caches
      - ~/.gradle/wrapper
      - .gradle

GitHub Actions:

- uses: actions/cache@v4
  with:
    key: gradle-${{ hashFiles('build.gradle.kts') }}
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
      .gradle

The two pipes (paths: → list, GitHub path: → multi-line string) are equivalent. GitHub also accepts a single-string path: with newlines escaped — both forms work.

Translation Pattern #3: Per-Branch Scoping

CircleCI's {{ .Branch }} template variable lets you scope caches per branch. GitHub uses ${{ github.ref_name }} for the same purpose.

CircleCI:

- restore_cache:
    keys:
      - cypress-{{ .Branch }}-{{ checksum "package-lock.json" }}
      - cypress-{{ .Branch }}-
      - cypress-main-

GitHub Actions:

- uses: actions/cache@v4
  with:
    path: ~/.cache/Cypress
    key: cypress-${{ github.ref_name }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      cypress-${{ github.ref_name }}-
      cypress-main-

Both platforms behave the same way: try the exact key first, then the most specific fallback (current branch, any lockfile), then a fallback to the main branch's most recent cache. The branch-aware fallback is what saves new branches from cold-cache 2-minute downloads.

Translation Pattern #4: Composite Keys with Multiple Inputs

For projects with multiple lockfiles (frontend package-lock.json, backend requirements.txt), the cache key combines multiple checksums.

CircleCI:

- restore_cache:
    keys:
      - deps-{{ checksum "package-lock.json" }}-{{ checksum "requirements.txt" }}
      - deps-{{ checksum "package-lock.json" }}-
      - deps-

GitHub Actions:

- uses: actions/cache@v4
  with:
    path: |
      node_modules
      ~/.cache/pip
    key: deps-${{ hashFiles('package-lock.json') }}-${{ hashFiles('requirements.txt') }}
    restore-keys: |
      deps-${{ hashFiles('package-lock.json') }}-
      deps-

GitHub's hashFiles() is more flexible than CircleCI's checksum: you can pass a glob (hashFiles('**/package-lock.json')) to hash every lockfile in a monorepo into a single value. That's worth using when the project has multiple sub-packages.

The Five Gotchas Nobody Warns You About

1. Cache key length

GitHub Actions truncates cache keys at 512 characters. CircleCI doesn't have a documented limit but warns at very long keys. If you're concatenating multiple hashFiles() calls, keep an eye on length — you'll silently truncate and get cache misses.

2. Cache size limits

GitHub Actions caps cache storage at 10GB per repository, with LRU eviction once you exceed it. CircleCI caches are unlimited but bill by GB-hour. If you're caching multi-gigabyte directories (node_modules in a monorepo, Docker layer caches, language sdk caches), you'll hit the GitHub limit faster than expected.

The fix: cache more selectively. Cache ~/.npm (download cache) instead of node_modules (installed package tree) when possible — npm ci from cache is nearly as fast as restoring node_modules and the cache is 5-10× smaller.

3. Restore-keys ordering

In both platforms, the order of restore-keys matters: the runtime tries the first key, then the second, then the third. A common mistake is listing fallbacks in the wrong direction:

# Wrong — most general first, never falls through
restore-keys: |
  deps-
  deps-${{ github.ref_name }}-
  deps-${{ github.ref_name }}-${{ hashFiles(...) }}
# Correct — most specific first, falls through on miss
restore-keys: |
  deps-${{ github.ref_name }}-
  deps-

(The exact-match key goes in key:, not restore-keys:. restore-keys: is fallbacks only.)

4. Cache and lockfile updates

If your key: includes hashFiles('package-lock.json') and your job updates the lockfile mid-run, the saved cache is associated with the old lockfile hash. Subsequent runs that hit the new lockfile will miss the cache. Either: don't update lockfiles in CI, or set key: to depend on something stable (like the git revision of main) and use restore-keys: for the lockfile-specific fallback.

5. The save phase runs even on failure

GitHub Actions saves the cache as a post-step at the end of the job by default — even if the job failed. This is usually what you want (capture the partially-built state for the next run). CircleCI's save_cache only runs if you reach that step, so a failure earlier in the job means no save. If your converted GitHub workflow is caching half-broken state, set actions/cache/save@v4 as an explicit step with if: success() to match CircleCI's behaviour.

Worked Example: A Real Conversion

CircleCI input (a typical Rust pipeline):

version: 2.1
jobs:
  test:
    docker: [{ image: rust:1.78 }]
    steps:
      - checkout
      - restore_cache:
          keys:
            - cargo-v2-{{ .Branch }}-{{ checksum "Cargo.lock" }}
            - cargo-v2-{{ .Branch }}-
            - cargo-v2-main-
      - run: cargo build --release
      - save_cache:
          key: cargo-v2-{{ .Branch }}-{{ checksum "Cargo.lock" }}
          paths:
            - ~/.cargo/registry
            - ~/.cargo/git
            - target
      - run: cargo test --release

GitHub Actions equivalent:

jobs:
  test:
    runs-on: ubuntu-latest
    container: rust:1.78
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: cargo-v2-${{ github.ref_name }}-${{ hashFiles('Cargo.lock') }}
          restore-keys: |
            cargo-v2-${{ github.ref_name }}-
            cargo-v2-main-
      - run: cargo build --release
      - run: cargo test --release

Five concrete changes:

  1. version: 2.1 and the workflows section disappear (GitHub structures workflows around jobs: directly).
  2. docker: [{ image: rust:1.78 }] becomes container: rust:1.78 (or runs-on: ubuntu-latest plus container: for clarity).
  3. The restore_cache step is replaced with actions/cache@v4 before the work, and save_cache disappears — the cache action's post-job hook handles saving automatically.
  4. {{ checksum "..." }} becomes ${{ hashFiles('...') }}.
  5. {{ .Branch }} becomes ${{ github.ref_name }}.

Automating the Mechanical Part

The five transformations above are deterministic. You can hand-translate them in 10 minutes per workflow, or run DevZone's CI/CD Configuration Converter to handle them automatically. The converter handles all four patterns in this post, surfaces every cache step in the audit log so you know what was translated, and flags edge cases (multi-step caches, caches with when: conditions, caches that span workflow boundaries) for manual review.

The audit log is the part you read carefully. Every restore_cache and save_cache becomes one entry in the log with a confidence level — usually lossless for simple cases, lossy for cases where CircleCI's behaviour didn't translate one-to-one. A non-empty audit log is normal; the goal is to understand it, not eliminate it.

When to Skip the Cache Conversion Entirely

A counter-intuitive piece of advice: when migrating from CircleCI to GitHub Actions, sometimes you should not port the cache configuration. Reasons:

  • Cache architecture differs. CircleCI caches are global to the project; GitHub Actions caches are scoped per-branch with cross-branch fallback. Your CircleCI cache key strategy may not map cleanly.
  • The first run will be slow regardless. The first time the GitHub workflow runs, every cache misses. Don't spend 30 minutes tuning cache keys before you've even seen what a cold run looks like.
  • actions/setup-node@v4 (and friends) handle caching for you. If your CircleCI cache is just for npm packages, the GitHub equivalent is to drop the explicit cache step entirely and add cache: npm to actions/setup-node@v4. Same outcome, three lines of YAML deleted.

The minimum viable migration: convert without the cache, run it once, see what's slow, then add caches surgically based on actual cold-run timings rather than copying CircleCI's configuration verbatim.

Closing

Cache conversion is the trickiest part of any CircleCI → GitHub Actions migration. The four patterns in this post cover ~90% of real pipelines; the last 10% (custom workspace persistence, intra-job caches, dynamic key generation) requires hand-tuning. Use the converter for the mechanical 90% and read the audit log carefully for the rest.

If you have a complex existing CircleCI setup, paste it into the CircleCI to GitHub Actions converter and walk through the audit one cache step at a time. The combination of mechanical translation plus disciplined audit-log review usually compresses a full pipeline migration from a multi-day project into a focused afternoon.

Try the tools