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:
{{ checksum "..." }}→${{ hashFiles('...') }}. The CircleCI checksum function reads the file and hashes its contents. GitHub'shashFiles()does the same thing but accepts globs (hashFiles('**/package-lock.json')is valid; the CircleCI version is single-file only).- The
keys:array splits — first entry becomeskey:, rest becomerestore-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:
version: 2.1and the workflows section disappear (GitHub structures workflows aroundjobs:directly).docker: [{ image: rust:1.78 }]becomescontainer: rust:1.78(orruns-on: ubuntu-latestpluscontainer:for clarity).- The
restore_cachestep is replaced withactions/cache@v4before the work, andsave_cachedisappears — the cache action's post-job hook handles saving automatically. {{ checksum "..." }}becomes${{ hashFiles('...') }}.{{ .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 addcache: npmtoactions/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.