Fast Ruby Builds on GitHub Actions

Ruby builds slow down on GitHub Actions from cold gem caches and 2 vCPU runners. Fix both with warp- labels, bundler-cache, and parallel RSpec.

Last verified:

Ruby builds run slow on GitHub Actions for two reasons that compound: the standard hosted Linux runner for private repositories carries 2 vCPUs, so RSpec and Minitest run close to serially, and every job starts with a cold gem cache, so Bundler resolves and recompiles the whole Gemfile again. The fix is a bigger runner plus a gem cache that survives between jobs: point runs-on at a warp- label, set bundler-cache: true on WarpBuilds/setup-ruby, and fan the suite out across every core.

Overview

A Ruby job on GitHub Actions spends its minutes in four places: bundle install, native gem compilation, asset precompilation for Rails apps, and the test run itself. On GitHub's standard hosted Linux runner for private repositories, which carries 2 vCPUs and 8 GB of RAM per GitHub's published runner specs (checked on 2026-08-13), the test run gets two processes at best, and the install phase pays full price on every single run.

WarpBuild runners register against your organization under warp- labels, and the Linux runner images carry the same tooling as GitHub-hosted runners, so Bundler, rbenv-style version pinning, Rails, RSpec, and Minitest all run unchanged. Runners are ephemeral virtual machines, freshly allocated for each job and destroyed when the job finishes. The full label list, machine shapes, and per-minute rates live in the cloud runners documentation.

The rest of this page covers a working Ruby workflow, sizing guidance for RSpec and Minitest suites from 4 to 16 vCPUs, the four bottlenecks that dominate Ruby jobs, and the arithmetic behind the bill. For the general case across every language, the checklist for speeding up GitHub Actions is the wider tour; this page stays on Ruby.

Configuration

The workflow below runs an RSpec suite on warp-ubuntu-latest-x64-8x (8 vCPUs, 32 GB RAM) with a Postgres service container and eight parallel test processes. Gems are installed and cached by WarpBuilds/setup-ruby, which is a drop-in replacement for ruby/setup-ruby that routes caching through the WarpBuild cache automatically on WarpBuild runners.

name: ruby-tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  rspec:
    runs-on: warp-ubuntu-latest-x64-8x
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      RAILS_ENV: test
      DATABASE_URL: postgres://postgres:postgres@localhost:5432
      PARALLEL_TEST_PROCESSORS: "8"
    steps:
      - uses: actions/checkout@v4

      - name: Install Ruby and gems
        uses: WarpBuilds/[email protected]
        with:
          ruby-version: "3.3"
          bundler-cache: true
          cache-version: 1

      - name: Prepare the parallel test databases
        run: bundle exec rake parallel:create parallel:load_schema

      - name: Run RSpec
        run: bundle exec parallel_rspec spec

Two details in that file carry most of the weight.

bundler-cache: true runs bundle install and caches the installed gems, so a separate bundle install step can be deleted from the workflow. The input defaults to false, so a workflow that omits it silently installs gems from scratch on every run while looking like it caches them. Set it explicitly.

cache-version is an arbitrary string folded into the gem cache key. Bumping it from 1 to 2 writes a fresh entry and abandons the old one, which is what you want after a Ruby patch upgrade, a Bundler upgrade, or an image change that leaves compiled native gems linked against the wrong system libraries. Leave it alone the rest of the time, because a version bump costs one full cold install.

For paths outside the gem bundle, WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 with the same path, key, and restore-keys inputs. A Rails app usually wants precompiled assets and the Bootsnap compile cache kept warm:

      - name: Cache assets and bootsnap
        uses: WarpBuilds/cache@v1
        with:
          path: |
            public/assets
            tmp/cache/assets
            tmp/cache/bootsnap
          key: ${{ runner.os }}-assets-${{ hashFiles('app/assets/**', 'app/javascript/**', 'package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-assets-

Cache entries are scoped to key, version, and branch, and the version hash covers both the compression tool and the cached paths, so a cache written on macOS never restores on Linux. Entries expire 7 days after their last use. Cache storage bills at $0.20 per GB-month with cache operations at $0.0001 each on hosted runners, and cache is included at no charge on BYOC runners. The full input reference for every WarpBuild setup-* fork is in the setup actions documentation.

Sizing

Ruby test frameworks parallelize by process, so runner size sets your parallelism directly. RSpec through parallel_rspec and Minitest through Rails' parallelize(workers: :number_of_processors) both fork one worker per core, and each worker wants its own test database.

Runner labelvCPURAMStoragePrice per minute
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032

4x, four test processes. Suites under roughly ten single-threaded minutes: model specs, service objects, a thin request-spec layer. 16 GB gives about 4 GB per process, which is comfortable when the only service container is Postgres. Gem installs also compile faster here than on a 2 vCPU machine, because native extension builds use every core.

8x, eight test processes. The default choice for a Rails monolith with system specs. 32 GB covers eight Rails processes, each holding a connection pool and a fixture set, plus a Postgres container and a Redis container on the same runner. Watch the Postgres side first: eight workers hammering one container will saturate its shared buffers before the Ruby processes saturate the CPU, so raise max_connections and shared_buffers in the service container before buying more cores.

16x, sixteen test processes. Large suites and integration matrices, especially ones running Capybara with a headless browser, which adds a browser process per worker on top of the Rails process. 64 GB is the reason to be here as much as the core count. Before paying for 16 vCPUs, check that your suite actually splits evenly; parallel_rspec balances by file size by default, and one 12 minute file pins the whole run to 12 minutes no matter how many workers wait idle.

Linux ARM64 runners carry lower per-minute rates at the same shapes, at $0.006, $0.012, and $0.024 for the 4x, 8x, and 16x sizes. Most gems with native extensions publish ARM64 builds now, and the rest compile from source on the runner, so ARM64 is worth a trial branch for a pure Ruby suite.

To size from data instead of guesswork, WarpBuild's CI observability reports CPU and memory from the runner agent correlated with the GitHub Actions job logs, which shows whether all eight processes saturate the machine or four of them sit blocked on the database.

What the sizes cost against GitHub-hosted list prices

GitHub publishes its per-minute Actions rates in the GitHub Actions billing reference, checked on 2026-08-13. WarpBuild rates come from the runner catalog above.

WarpBuild runnerShapeWarpBuild per minuteGitHub-hosted equivalentGitHub per minuteDifference
warp-ubuntu-latest-x64-2x2 vCPU, 8 GB$0.004ubuntu-latest on private repositories$0.00633 percent lower list price
warp-ubuntu-latest-x64-4x4 vCPU, 16 GB$0.0084-core Linux larger runner$0.01233 percent lower list price
warp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.0168-core Linux larger runner$0.02227 percent lower list price
warp-ubuntu-latest-x64-16x16 vCPU, 64 GB$0.03216-core Linux larger runner$0.04224 percent lower list price

A worked model makes the shape clear. Assume an RSpec suite that takes 24 minutes on GitHub's standard 2 vCPU hosted runner and runs 500 times a month, and assume your own timing shows eight parallel processes bring wall clock to 7 minutes. Substitute your real numbers, because parallel efficiency depends on how evenly your spec files split.

SetupWall clockRate per minuteCost per runCost per month at 500 runs
GitHub-hosted, 2 vCPU24 min$0.006$0.144$72.00
GitHub-hosted 8-core larger runner7 min$0.022$0.154$77.00
warp-ubuntu-latest-x64-8x7 min$0.016$0.112$56.00

Buying more cores from GitHub raises the monthly bill even though the suite finishes sooner, because the rate climbs faster than the wall clock falls. At the same 8 vCPU shape, the WarpBuild rate is lower, so the shorter suite also costs less than the 24 minute status quo.

Bottlenecks

Four bottlenecks account for most slow Ruby jobs. Each has a specific fix.

A cold vendor/bundle on every run

Without a restored cache, every job resolves the Gemfile, downloads every gem, and rebuilds the bundle. A mid-sized Rails Gemfile with 250 gems turns that into minutes of pure setup before a single test runs. bundler-cache: true fixes it, and the key is derived from your Gemfile.lock, so the cache hits exactly when the dependency set is unchanged and misses when it moves. Keep Gemfile.lock committed; without it the key has nothing stable to hash.

Native gem compilation

nokogiri, pg, mysql2, grpc, and sassc compile C extensions when no precompiled platform gem matches the runner. The symptom is an install step whose duration swings from 20 seconds to several minutes depending on which gems moved. Two mitigations: run bundle lock --add-platform x86_64-linux (or aarch64-linux on ARM64 runners) so Bundler resolves to precompiled platform gems where they exist, and keep the gem cache warm so a compile happens once per lockfile change rather than once per job. When a compile is unavoidable, extra vCPUs help directly, because extension builds parallelize across cores.

Asset precompilation

rails assets:precompile runs Sprockets or the JavaScript bundler on every job that needs a full app boot, including system specs. This work is deterministic for a given set of asset sources, so it belongs in a cache keyed on those sources, exactly as the second workflow snippet above does. Keep tmp/cache/assets in the cached paths alongside public/assets, because the incremental compile reads it to skip unchanged files. The Bootsnap compile cache is worth caching in the same step; it removes a repeated boot cost from every process a parallel run forks.

A serialized suite that never fans out

The most common waste is a suite that runs on one process on a machine with eight. rspec spec uses a single process by default, and Minitest outside Rails does the same. Adopt parallel_rspec or parallelize(workers: :number_of_processors), create one database per worker (parallel:create and parallel:load_schema handle this for parallel_tests, and Rails names them app_test-0, app_test-1, and so on), and confirm the split is even before buying a larger runner. A suite pinned by one long file gets nothing from more cores until that file is split.

When a failure only reproduces inside GitHub Actions, the WarpBuild Action Debugger pauses the workflow and opens an SSH session on the live runner, which is faster than adding puts statements and re-running a 20 minute suite. For suites that stand up their dependencies with Compose instead of service containers, the Docker Compose integration tests page covers that setup.

Proof

Teams running Python services next to a Rails app can apply the same runner and cache pattern there; the Python test suites page covers the pytest equivalents, and the dependency caching answer covers cache keys in general.

FAQ

Do I need to change my Ruby workflow to run on WarpBuild runners?

Change the runs-on label to a warp- label such as warp-ubuntu-latest-x64-8x, and swap ruby/setup-ruby for WarpBuilds/[email protected]. Your Gemfile, RSpec configuration, and rake tasks stay the same.

Does bundler-cache: true replace my bundle install step?

Yes. With bundler-cache: true, WarpBuilds/setup-ruby runs bundle install and caches the installed gems, so a separate bundle install step can be removed. The input defaults to false, so the workflow has to set it explicitly.

How do I force a gem cache rebuild?

Bump cache-version on WarpBuilds/setup-ruby. It is an arbitrary string folded into the cache key, so changing it writes a fresh entry. Use it after a Ruby patch upgrade or a system library change that leaves compiled native gems stale.

How many parallel test processes should a Ruby suite run?

Start with one process per vCPU: 4 on warp-ubuntu-latest-x64-4x, 8 on the 8x, and 16 on the 16x. Lower the count when processes contend on a single Postgres service container, and give every process its own test database.

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.