How Do I Cache Bundler Gems?
Point bundler at vendor/bundle, cache that directory keyed on Gemfile.lock plus the Ruby version, and install in deployment mode. Workflow YAML and costs.
Cache vendor/bundle and key the entry on a hash of Gemfile.lock plus the Ruby version and the runner platform. Point bundler at that same directory first with bundle config set --local path vendor/bundle, so the cache path and the install path are one directory, and run bundle install in deployment mode so a Gemfile.lock that no longer matches the Gemfile fails the job instead of resolving new versions on the runner.
Answer
Bundler installs into a system gem directory unless you tell it otherwise, and that directory sits outside the workspace. A cache step pointed at the workspace then stores nothing useful, and one pointed at the system path stores a directory the next run rebuilds anyway. The fix is one config command that moves the install target into the repository, where a cache action can name it.
Three bundler settings carry this setup. Each is written to .bundle/config in the working directory by bundle config and read by every later bundle command in the job.
| Setting | Command | What it does |
|---|---|---|
path | bundle config set --local path vendor/bundle | Installs gems into vendor/bundle under the checkout, which is the directory you cache |
deployment | bundle config set --local deployment true | Requires a checked-in Gemfile.lock that matches the Gemfile, and defaults the install path to vendor/bundle |
without | bundle config set --local without "development" | Skips groups the job does not need, which keeps the cached directory smaller |
Deployment mode is the setting that turns a lockfile drift into a failed job. Bundler's deployment guide describes the mode as the one for rolling a bundle out to production or to a build machine: the lockfile has to exist in version control, and bundler refuses to update it. A pull request that edits the Gemfile and forgets to commit the regenerated Gemfile.lock stops with a message telling you to run bundle install elsewhere and commit the result. Without deployment mode the same pull request resolves whatever versions the runner finds that day, the tests pass against gems nobody reviewed, and the cache key still matches the stale lockfile.
The cache key needs three inputs, because three separate things invalidate a vendor/bundle directory.
${{ runner.os }}-${{ runner.arch }}-gems-${{ steps.ruby.outputs.version }}-${{ hashFiles('**/Gemfile.lock') }}| Key part | Invalidates when | Why the directory is dead without it |
|---|---|---|
runner.os and runner.arch | The job moves between Linux, macOS, x64, or ARM64 | Compiled extensions are built for one platform triple |
| Ruby version | The toolchain moves to a new Ruby | Bundler writes to a per-ABI subdirectory and recompiles extensions |
hashFiles('**/Gemfile.lock') | Any gem version changes | The lockfile is the exact manifest of what belongs in the directory |
hashFiles returns a single hash over every matching file, so a monorepo with several Gemfile.lock files gets one key that changes when any of them does. Pair the key with a restore-keys prefix that drops the lockfile hash, and a single gem bump restores the previous directory and installs the difference rather than downloading every gem again. Cache key covers prefix fallbacks in general.
On WarpBuild runners the WarpBuild cache is a drop-in replacement for actions/cache@v4 and is enabled by default on Linux runners. Caching is not supported on the Windows runners, so a Windows Ruby job keeps using GitHub's cache backend. Entries expire 7 days after their last use, and the cache version is derived from the compression tool and the cached paths, which is a second reason a macOS entry never restores onto a Linux runner.
Detail
A workflow that restores, installs, and saves on success
name: test
on:
push:
branches: [main]
pull_request:
jobs:
rspec:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v5
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3.9"
- name: Record the Ruby version
id: ruby
run: echo "version=$(ruby -e 'print RUBY_VERSION')" >> "$GITHUB_OUTPUT"
- name: Point bundler at vendor/bundle
run: |
bundle config set --local path vendor/bundle
bundle config set --local deployment true
bundle config set --local without "development"
- name: Restore gems
id: gems
uses: WarpBuilds/cache/restore@v1
with:
path: vendor/bundle
key: ${{ runner.os }}-${{ runner.arch }}-gems-${{ steps.ruby.outputs.version }}-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gems-${{ steps.ruby.outputs.version }}-
- run: bundle install --jobs 4
- run: bundle exec rspec
- name: Drop gems the lockfile no longer names
if: success() && github.ref == 'refs/heads/main'
run: bundle clean --force
- name: Save gems
if: success() && github.ref == 'refs/heads/main'
uses: WarpBuilds/cache/save@v1
with:
path: vendor/bundle
key: ${{ steps.gems.outputs.cache-primary-key }}Four details carry the shape. The config step runs before the restore step, so the restored directory is the one bundler is about to use. bundle install runs on every path through the workflow, including a full cache hit, because the install is what verifies the lockfile against deployment mode. bundle clean --force removes gems that the current lockfile no longer names, which stops a year of version bumps from riding along in the entry. And the save step reuses cache-primary-key from the restore step, so the two keys cannot drift apart when someone edits one of them. Saving only from the default branch keeps five active branches from storing five near-identical copies, and every branch can still restore the default branch entry through GitHub's cache scoping.
Gems that compile native extensions
Pure Ruby gems unpack and are done. Gems with C extensions, such as database drivers, XML parsers, and image libraries, compile against the headers of the Ruby that is installed when bundle install runs. Bundler files the results by ABI and platform:
Path under vendor/bundle | Holds |
|---|---|
ruby/3.3.0/gems/ | Unpacked gem sources |
ruby/3.3.0/extensions/x86_64-linux/3.3.0/ | Compiled native extensions for one platform and ABI |
ruby/3.3.0/cache/ | Downloaded .gem archives |
ruby/3.3.0/bin/ | Binstubs |
The 3.3.0 in those paths is the Ruby ABI version, shared by every 3.3.x release, and the x86_64-linux segment is the platform triple. Move to Ruby 3.4 and bundler writes a sibling ruby/3.4.0 tree and compiles every extension again, while the restored 3.3.0 tree sits there adding restore seconds and gigabytes. Move the same job from warp-ubuntu-latest-x64-4x to warp-ubuntu-latest-arm64-4x and the extensions directory is wrong in the platform segment instead. Both cases are why the Ruby version and runner.arch belong in the key rather than the lockfile hash alone.
Precompiled platform gems add one more failure mode. Bundler records the platforms a lockfile supports, and in deployment mode a runner whose platform is missing from that list stops with an error rather than falling back to compiling from source. Run bundle lock --add-platform for each platform your workflows use, commit the updated lockfile, and an ARM64 job stops being a surprise:
bundle lock --add-platform x86_64-linux aarch64-linuxThe shorter path
WarpBuilds/setup-ruby does this whole setup in one step. With bundler-cache: true it runs bundle install and caches the installed gems automatically, and a separate bundle install step can be removed. The setup actions reference lists its inputs, including cache-version, an arbitrary string that busts the gem cache when you need a clean entry.
- uses: WarpBuilds/[email protected]
with:
ruby-version: "3.3.9"
bundler-cache: trueTake the managed version when the workflow is a normal bundle install and a test command. Take the explicit version above when you need the save step conditioned on branch or on success, a bundle clean pass before the save, or a path list that also covers assets or a compiled extension directory outside the bundle.
What the cache costs
Cache work is billed separately from runner minutes, at the rates on the pricing page, checked on 2026-08-13:
| Line item | Hosted runners | BYOC |
|---|---|---|
| Cache storage | $0.20 per GB-month | Free |
| Cache write, restore, or list | $0.0001 per operation | Free |
A worked month for a Rails-sized bundle, with the assumptions stated so you can substitute your own. One vendor/bundle entry of 400 MB, with two superseded entries still inside the 7 day window, is 1.2 GB of storage at $0.24 per month. At 800 workflow runs in the month, each doing one restore, plus 200 saves from the default branch, that is 1,000 operations at $0.10 per month. The runner minutes are the rest of the bill: 800 runs at 6 minutes each on warp-ubuntu-latest-x64-4x, which bills $0.008 per minute, is 4,800 minutes at $38.40. Cache spend lands at $0.34 against $38.40 in minutes, so trimming the entry pays off in the restore seconds every job spends rather than in the storage line.
One ceiling is worth planning around on GitHub's own cache backend. GitHub caps the combined size of all caches in a repository at 10 GB by default and evicts least recently used entries once the total passes it, checked on 2026-08-13. A Ruby monorepo with a bundle per service reaches that number sooner than a single application does.
The guide to persistent caches for GitHub Actions works through choosing between a cache action and a snapshot.
Related Questions
Which directory do I cache when I cache bundler gems?
Cache vendor/bundle, the directory bundler installs into once you run bundle config set --local path vendor/bundle. Run that config step before the restore step, so the cached directory is the install target. Caching a system gem directory instead leaves bundler installing into a path the next run does not restore, and the job pays the full install every time. Cache key covers the key that goes with the path.
Why does my gem cache miss after a Ruby version bump?
Bundler installs into vendor/bundle/ruby/<ABI>, where <ABI> is a directory such as 3.3.0, and compiled native extensions live under an extensions subdirectory named for both the platform triple and that ABI. A new Ruby writes a sibling directory and compiles fresh extensions, so a key built from Gemfile.lock alone restores a tree the new Ruby ignores. Put the Ruby version in the key and let the bump start a clean entry. Ruby builds on GitHub Actions covers runner sizing for the same workloads.
Do I still need to run bundle install when the cache hits?
Yes. Keep bundle install on every run. On a full hit it confirms that every gem in Gemfile.lock is present and exits quickly, on a partial hit from a restore-keys prefix it installs the gems the previous lockfile did not have, and in deployment mode it fails the job when the lockfile no longer matches the Gemfile. Rails builds on GitHub Actions applies the same pattern alongside asset and database setup, and the persistent caches guide covers what to do when the restore itself becomes the slow step.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.