Cache Ruby Dependencies in CI Pipelines

Khimananda Oli 7 min read Programming and Languages
Cache Ruby Dependencies in CI Pipelines

By Khimananda Oli | Last reviewed: August 2026

Slow builds kill developer momentum and inflate cloud costs, especially when your team runs dozens of pipeline executions daily. If you need to cache Ruby dependencies in CI pipelines effectively, the solution lies in correctly configuring Bundler’s installation path alongside your CI provider’s native caching mechanism. This guide provides battle-tested configurations for GitHub Actions and GitLab CI that actually persist between runs.

Many teams adopt general build caching strategies but fail with Ruby because they cache the wrong directory or use volatile keys. In my experience helping Nepal-based startups and global clients optimize their delivery workflows, misconfigured Ruby caches are the single most common source of "flaky" CI performance. When done right, you reduce dependency installation from minutes to seconds.

Gemfile.lockSource of TruthHash Key GenSHA-256 ChecksumCache LookupMatch / Missvendor/bundleRestored / Saved
Figure 1: Correct workflow to cache Ruby dependencies in CI pipelines relies on deterministic Gemfile.lock hashing

How do you configure Bundler paths for reliable caching?

The most frequent mistake engineers make when attempting to cache Ruby dependencies in CI pipelines is relying on system-wide gem directories. Default Bundler behavior installs gems to a global location like /usr/local/bundle or a user-specific path. These locations are problematic in CI because they often require root permissions to write, vary between container images, and contain extraneous files that bloat the cache archive.

Set a local vendor path

Always configure Bundler to install dependencies into a project-local directory. This makes the cache portable, permission-safe, and predictable across different runner environments.

# .github/workflows/ci.yml or .gitlab-ci.yml setup step
- name: Configure Bundler
  run: |
    bundle config set --local path 'vendor/bundle'
    bundle config set --local deployment 'true'
    bundle config set --local without 'development test'
  • path 'vendor/bundle': Isolates gems within the workspace, making them easy to target with cache actions.
  • deployment 'true': Enforces strict Gemfile.lock adherence. If the lockfile is out of sync, the build fails immediately rather than silently updating dependencies.
  • without 'development test': Excludes unnecessary groups in production-focused pipeline stages, reducing cache size and install time.

This configuration should be committed to your repository via .bundle/config if possible, ensuring consistency between local development and CI. However, setting it explicitly in the pipeline guarantees the environment matches your cache key expectations regardless of developer machine state.

How do you implement caching in GitHub Actions for Ruby?

GitHub Actions provides the official ruby/setup-ruby action, which includes built-in caching support. This is superior to manual cache steps because it understands Ruby versioning and Bundler internals natively. For teams managing complex reusable workflow architectures, encapsulating this setup in a composite action prevents configuration drift.

Optimal ruby/setup-ruby configuration

- uses: ruby/setup-ruby@v1
  with:
    ruby-version: '3.3'
    bundler-cache: true
    cache-version: 1

Setting bundler-cache: true automatically handles three critical tasks: it sets the bundle path to vendor/bundle, computes a hash of your Gemfile.lock combined with the Ruby version, and restores/saves the cache. The optional cache-version parameter allows you to manually invalidate stale caches without changing dependencies—useful when debugging corruption issues.

Fallback manual caching strategy

If you cannot use setup-ruby (e.g., custom Docker containers), use the generic cache action with explicit keys:

- name: Cache Ruby Gems
  uses: actions/cache@v4
  with:
    path: vendor/bundle
    key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
    restore-keys: |
      ${{ runner.os }}-gems-

The restore-keys fallback is essential. When your Gemfile.lock changes, an exact match fails. The prefix match restores the previous cache, allowing Bundler to only fetch updated gems rather than downloading everything from scratch. This partial restoration typically reduces install time by 70–80% even on dependency updates.

CACHE HIT PATH (~5 seconds)Restore ArchiveSkip Downloadbundle check OKCACHE MISS PATH (~2-5 minutes)No MatchPartial Restorebundle installSave New Cache
Figure 2: Cache hit skips network entirely; miss leverages partial restore to minimize bundle install duration

How do you cache Ruby dependencies in GitLab CI?

GitLab CI uses a different caching primitive based on key and paths directives. Unlike GitHub’s action-based approach, GitLab’s cache is declarative within the job definition. Teams migrating from other platforms often overlook GitLab’s distributed cache limitations—runners may not share cache instantly across regions, affecting self-hosted runner fleets deployed across multiple data centers.

Standard GitLab CI Ruby cache block

test:
  image: ruby:3.3
  variables:
    BUNDLE_PATH: vendor/bundle
    BUNDLE_DEPLOYMENT: "true"
  cache:
    key:
      files:
        - Gemfile.lock
    paths:
      - vendor/bundle
  script:
    - bundle install
    - bundle exec rspec

The key.files directive automatically hashes the specified file content. This is functionally equivalent to GitHub’s hashFiles() but integrated into the YAML schema. Always define BUNDLE_PATH as a variable rather than relying on inline bundle config commands—this ensures the path is available during both cache restoration and the install step.

Handling multi-project monorepos

In monorepo setups where multiple Ruby services share a runner, namespace your cache keys to prevent cross-contamination:

cache:
  key:
    prefix: api-service
    files:
      - services/api/Gemfile.lock
  paths:
    - services/api/vendor/bundle

Without the prefix, two projects with identical Gemfile.lock hashes could overwrite each other’s caches, causing intermittent build failures that are notoriously difficult to diagnose.

What are common cache invalidation pitfalls and fixes?

Even with correct configuration, caching Ruby dependencies in CI pipelines can fail silently. Understanding these failure modes separates junior implementations from production-grade setups.

PitfallSymptomFix
Caching system gemsPermission errors, massive cache size (>500MB)Always use vendor/bundle with local config
Missing OS in cache keyLinux/macOS runners sharing incompatible binariesPrefix keys with ${{ runner.os }} or $CI_RUNNER_TAGS
Ruby version mismatchNative extension load failures after upgradeInclude Ruby version in cache key hash
Stale lockfileCache hits but runtime missing new gemsEnable BUNDLE_DEPLOYMENT=true to fail fast
Unbounded cache growthStorage costs rising, slow uploadsAdd cache-version for manual rotation

Debugging cache misses

When caches consistently miss despite unchanged dependencies, verify these three items:

  1. Line endings: Windows-generated Gemfile.lock files with CRLF endings produce different hashes than LF. Normalize via .gitattributes.
  2. Path consistency: Ensure BUNDLE_PATH matches exactly between cache restore and bundle install steps. Trailing slashes matter.
  3. Runner architecture: ARM64 and x86_64 runners cannot share caches containing native gems. Include architecture in your key.
Cache Miss DetectedIs Gemfile.lock unchanged?NOYESExpected: Partial RestoreProblem: Config MismatchCheck Path + Ruby VersionVerify Line Endings (CRLF)Confirm Runner Arch Match
Figure 3: Troubleshooting decision tree for diagnosing why caching Ruby dependencies in CI pipelines fails unexpectedly

Should you use Docker layer caching instead of Bundler caching?

For containerized Ruby applications, Docker layer caching complements—but does not replace—Bundler-level caching. A well-structured Dockerfile copies Gemfile and Gemfile.lock before application code, creating a stable layer that survives code changes. However, this only helps during image builds, not during test jobs that run directly on VM runners.

In practice, use both: Docker layer caching for deployment pipelines, and Bundler caching for test/lint jobs. This dual approach ensures fast feedback loops during development while maintaining efficient production image builds. Teams following lean CI/CD practices often see the highest ROI from this hybrid strategy, as test jobs typically outnumber deploy jobs 10:1.

Conclusion

Properly configured, caching Ruby dependencies in CI pipelines transforms sluggish feedback cycles into responsive development workflows. The core principles remain constant across platforms: isolate gems to vendor/bundle, derive cache keys deterministically from Gemfile.lock, and validate your configuration against real pipeline runs rather than assuming defaults work. Start by auditing your current cache hit rates in your CI dashboard—if you’re below 90% on unchanged branches, apply the configurations above. Need help optimizing your Ruby infrastructure or designing compliant CI systems? Reach out to discuss your specific pipeline challenges.

Frequently Asked Questions

Use the ruby/setup-ruby action with bundler-cache set to true. This automatically caches gems based on your Gemfile.lock hash, restoring them in subsequent runs without manual configuration or extra steps.

Cache misses usually occur when Gemfile.lock changes, the runner OS updates, or the cache key includes volatile variables. Ensure your key relies solely on the lock file hash and Ruby version for consistent hits across pipeline runs.

Yes. Restoring cached gems avoids repeated bundle install executions, cutting build times by several minutes per run. Over hundreds of monthly builds, this significantly reduces compute usage and associated cloud billing costs.

Hash the Gemfile.lock file combined with the Ruby version. This ensures the cache invalidates only when dependencies actually change, preventing stale gem installations while maximizing hit rates across feature branches and pull requests.

Yes. Define a cache policy using files containing Gemfile.lock as the key. Set the paths to vendor/bundle and configure Bundler to install locally. Use pull-push policy on main and pull-only on feature branches.

Caching vendor/bundle is standard practice but exclude platform-specific native extensions if running multi-OS matrices. Otherwise, cache the full directory to avoid recompiling gems like nokogiri or pg during every restore step.

The setup-ruby action integrates Bundler caching natively with correct key generation and path handling. Using actions/cache manually requires replicating this logic and often leads to misconfigured keys or missed restoration steps.

Yes, because CI caches are scoped to repositories and branches. However, never cache credentials or environment secrets. Only store compiled gems and metadata, which contain no sensitive application data or tokens.

Permission issues arise when cache was created under a different user context. Always configure Bundler to use a project-local path via bundle config set --local path vendor/bundle before installation to ensure consistent ownership.

Outdated gems persist until the cache key changes. Since keys derive from Gemfile.lock, updating dependencies automatically generates a new key. Old caches expire based on platform TTL, typically seven days without access.

Absolutely. Mount the gem cache volume or use layer caching with COPY Gemfile* followed by bundle install. This prevents reinstalling unchanged dependencies across container rebuilds, dramatically speeding up image creation and test execution.

Most Rails application gem caches range between 200MB and 500MB depending on dependencies. Monitor cache size regularly since exceeding platform limits causes silent failures. Prune unused gems periodically to maintain optimal storage efficiency.

Yes, but each matrix combination needs its own cache key including the Ruby version and OS. Shared caches across incompatible environments cause build failures. Configure distinct keys per matrix entry to ensure correct restoration.

Enable verbose logging in your CI tool and check post-job cache save logs. Verify the computed key matches expectations and that the target path exists. Missing directories or key mismatches are common silent failure causes.

Warming on main ensures all feature branches inherit a complete baseline cache. Configure push policies on protected branches and read-only policies elsewhere. This prevents cache pollution from experimental dependency changes in development branches.