Caching Dependencies and Sharing Artifacts
Speed up GitHub Actions with dependency caching and move build outputs between jobs with artifacts. When to use each, cache keys and restore-keys, and upload/download.
Two features in GitHub Actions get mixed up constantly because both put files somewhere and pull them back later: caching and artifacts. They solve different problems. Caching is a transparent speedup - you reuse downloaded dependencies so repeated runs do not re-fetch the same packages every time. Artifacts are outputs you deliberately keep - a built binary, a coverage report, a Docker image tarball - that you either download yourself or hand from one job to another. Get the distinction right and your pipelines get faster and simpler; get it wrong and you either fight flaky caches or lose the file you actually needed. This guide covers both, with the keys and patterns that make them work.
Caching vs artifacts: the one distinction
Hold this in your head before any YAML:
- Cache is an optimization. It stores dependencies (the
node_modulesyour package manager would otherwise re-download, the~/.m2Maven repo, the pip wheel cache) so the next run restores them instead of hitting the network. If the cache is missing or stale, the build still works - it is just slower. You never depend on a cache being there. - Artifact is a result. It stores files your workflow produced (a compiled app, a test report, a release bundle) so you can download them from the run's summary page, or so a later job can pick them up. If the artifact is missing, the thing that needed it fails. You do depend on it.
The rule of thumb: if losing the files only costs you time, it is a cache. If losing the files breaks something, it is an artifact. Caches are keyed and shared across runs to save work; artifacts belong to a single run and are things you keep.
Caching with actions/cache
The actions/cache action saves a directory after a run and restores it at the start of the next run, keyed by a string you choose. The whole game is the key: it should change exactly when the cached contents should change, and stay the same when they should not.
The standard pattern keys the cache on a hash of your lockfile. When the lockfile is unchanged, dependencies are unchanged, so you want the same cache. When the lockfile changes (someone added or bumped a package), the hash changes, so you get a fresh cache.
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- run: npm ci
- run: npm test
Read the key: npm-Linux-<hash-of-lockfile>. On the first run there is no match, so the step reports a cache miss, runs npm ci normally, and then saves ~/.npm under that key when the job finishes successfully. On the next run, if the lockfile is identical, the key matches exactly - a cache hit - and ~/.npm is restored, so npm ci installs from the local cache instead of downloading everything again.
restore-keys: partial hits
restore-keys is what you fall back to when the exact key does not match. If the lockfile changed, the full key misses - but you probably still have most of the same packages from last time. restore-keys is a list of key prefixes; on an exact miss, GitHub restores the most recent cache whose key starts with one of these prefixes.
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
So when you bump one package, the exact key misses, but npm-Linux- matches the previous cache. You restore last run's dependencies (almost all still valid), and npm ci only fetches the delta. This is the difference between a warm cache that saves 90 percent of install time and a cold cache that saves nothing. Always set restore-keys unless you specifically want all-or-nothing behavior.
One subtlety: a cache entry is immutable once written. If the exact key already exists, actions/cache will not overwrite it - it just skips the save. That is why the key must include the lockfile hash. If you key on something static like npm-cache, the first run writes it and every future run restores that same stale copy forever, even after you change dependencies.
What to cache
Cache the package manager's download cache, not the installed dependency tree, whenever you can. For npm that is ~/.npm, not node_modules. The download cache is portable and safe to restore; the installed tree can contain platform-specific builds or symlinks that do not restore cleanly. Common targets:
- npm:
~/.npm, keyed onpackage-lock.json - yarn:
~/.cache/yarn(or the Berry cache), keyed onyarn.lock - pip:
~/.cache/pip, keyed onrequirements.txt - Maven:
~/.m2/repository, keyed onpom.xml - Gradle:
~/.gradle/caches, keyed on the gradle lockfiles - Go: the module and build cache, keyed on
go.sum
The point is the same everywhere: cache the thing that is expensive to fetch and cheap to key on a lockfile.
Cache scope and eviction
Caches are not global forever. A few limits shape how they behave:
- Scope by branch. A cache created on a branch is available to that branch and to child branches (and to the default branch). A cache created on
mainis available to all branches. But a cache made onfeature-xis not visible tofeature-y. This is a security boundary - it stops one PR from poisoning another's cache. - Eviction by age and size. GitHub removes cache entries not accessed in 7 days, and enforces a total size limit per repository (10 GB by default). When you go over, the least recently used caches are evicted first. So do not treat the cache as durable storage - it can vanish, which is fine, because the build works without it.
Because of eviction, never make correctness depend on a cache. It is a speedup that may or may not be there.
The easy path: setup actions with built-in cache
Most of the time you should not write actions/cache by hand at all. The official language setup actions have caching built in - you enable it with one line, and they handle the path, the key, and the lockfile hashing for you.
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # caches ~/.npm, keyed on package-lock.json
- run: npm ci
- run: npm test
That cache: npm does everything the manual actions/cache block above did, correctly, with sensible keys and restore-keys. The same option exists across the ecosystem: setup-node (cache: npm / yarn / pnpm), setup-python (cache: pip / poetry), setup-java (cache: maven / gradle), setup-go (caching on by default). Reach for these first. Only drop down to raw actions/cache when you are caching something the setup action does not cover, like a build cache, a compiled dependency, or a custom tool download.
Sharing files with artifacts
Artifacts solve a different problem: getting a file out of a run, or moving it between jobs. Jobs in a workflow run on separate, isolated runners with no shared filesystem, so a file built in one job does not exist in another. Artifacts are the bridge.
Use actions/upload-artifact to save files, and actions/download-artifact to retrieve them. Uploaded artifacts also appear on the workflow run's summary page in the GitHub UI, so you can download them by hand - handy for build outputs, logs, or reports you want to inspect.
Build in one job, deploy in another
The classic pattern: a build job compiles the app and uploads the result; a deploy job waits for build (via needs), downloads that result, and ships it. The build only runs once, and the deploy job gets the exact bytes the build produced.
name: Build and deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build # produces ./dist
- name: Upload build output
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
deploy:
needs: build # wait for build to finish and pass
runs-on: ubuntu-latest
steps:
- name: Download build output
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: ./scripts/deploy.sh dist/
The needs: build is what makes this correct: deploy does not start until build succeeds, and download-artifact pulls the dist artifact by name into the deploy runner. Now the built files exist in the second job even though it is a fresh machine. Note that cache (dependencies) and upload-artifact (the build output) do complementary jobs here - the cache made the install fast, the artifact carried the result forward.
Retention
Artifacts are stored against the run and expire on a schedule you can control. retention-days sets how long GitHub keeps them (up to 90 days on most plans, with a repository default you can configure). Set it short for throwaway build outputs to save storage, longer for things like release bundles or reports you may need to fetch later. When the retention window passes, the artifact is deleted automatically.
Unlike caches, artifacts count against your account's storage quota and do not evict themselves early by LRU - they live until their retention expires or you delete them. So keep retention-days modest for CI outputs you only need for the length of the run.
Choosing between them
When you are deciding which to use, ask what the files are for:
- Moving files between jobs in the same run (build to deploy, build to test) -> artifacts with
needs. This is the only tool that does it; a cache is keyed across runs, not scoped to one run's job graph. - Speeding up repeated installs across runs -> cache (or the setup action's built-in cache). You do not care about the exact files, only that the network work is skipped.
- Producing something a human or a later system needs to download and keep (binaries, reports, logs) -> artifacts, with a retention that matches how long you need it.
The failure mode to avoid is trying to pass a build output between jobs with a cache. It sometimes appears to work, then breaks intermittently when the cache is evicted or a key collides, because a cache is explicitly allowed to disappear. Build outputs that a downstream job requires are artifacts, full stop.
The shape of it
Caching and artifacts look similar and do opposite jobs. A cache is a transparent speedup: store dependencies keyed on a lockfile hash, add restore-keys for partial hits when the lockfile changes, and prefer the setup action's built-in cache: option over writing actions/cache yourself. Never depend on it - it can be evicted after 7 days or on size pressure, and the build must still work without it. An artifact is a result you keep: upload-artifact in the job that produces a file, download-artifact in a later job wired with needs, and a retention-days that fits how long you need it. If losing the files only costs time, cache them; if losing the files breaks something, make them an artifact. For where these fit in a full workflow, see the CI pipelines guide.