Skip to content
compiler.dev

Speed Up Rails in GitHub Actions

Last updated

To speed up Rails in GitHub Actions, cache your gems with ruby/setup-ruby, avoid precompiling assets you do not test, prepare the database from a schema file instead of running every migration, and split your test suite across parallel processes and jobs. Most Rails CI time goes to boot, database setup and one long single-process test run.

This is the Rails version of our general guide to faster GitHub Actions. The same ideas apply to RSpec and Minitest.

Where Rails CI spends its time

Run one build and read the step timings. For a typical Rails app the order is: gem install (cache it), database setup (often 20 to 60 seconds), JavaScript and asset build (if you have system tests), and the test run itself. The test run is usually the largest. Fix them in order of effort, not in order of size.

Cache gems the easy way

ruby/setup-ruby can install and cache gems for you. Its README says bundler-cache: true runs bundle install and caches the gems automatically, using the lockfile in the cache key.

- uses: ruby/setup-ruby@v1
  with:
    bundler-cache: true

The Ruby version comes from .ruby-version or .tool-versions if you do not set ruby-version. If a cached gem set gets corrupted, set cache-version: 1 (any value other than 0) to start over. Do not add a second actions/cache step for vendor/bundle; the action already does it.

Prepare the database from the schema

A fresh Postgres service starts empty. Running 400 migrations on every job is slow and fragile. Load the schema instead. Here is a full job with Postgres and Redis services:

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 25
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: postgres
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s --health-timeout 5s --health-retries 10
      redis:
        image: redis:7
        ports: ["6379:6379"]
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      RAILS_ENV: test
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test
      REDIS_URL: redis://localhost:6379/0
    steps:
      - uses: actions/checkout@v7
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - run: bin/rails db:schema:load
      - run: bundle exec rspec

Commit db/schema.rb or db/structure.sql and keep it current. Health checks matter: without them the first test can run before Postgres accepts connections, and you get random failures that look like flaky tests.

A throwaway test database does not need durability. If you start Postgres yourself, -c fsync=off -c synchronous_commit=off -c full_page_writes=off makes writes cheaper. Never use that for real data.

Bootsnap and boot time

Bootsnap caches Ruby load paths and compiled bytecode, and is in the default Gemfile of recent Rails apps. Its cache lives in tmp/cache/bootsnap, which is empty on a fresh VM, so the first boot is slow. Cache it:

- uses: actions/cache@v6
  with:
    path: tmp/cache/bootsnap
    key: bootsnap-${{ runner.os }}-${{ hashFiles('Gemfile.lock', '.ruby-version') }}

The gain is a few seconds per job. It matters most when you split into many parallel jobs, since each pays boot cost.

Skip work your tests do not need

  • Assets. If only system tests need compiled assets, run bin/rails assets:precompile only in that job. Cache node_modules through actions/setup-node.
  • Eager loading. Setting config.eager_load = ENV["CI"].present? in the test environment catches load errors, but it adds boot time per process. Consider it for one job only.
  • Logging. Set config.log_level = :warn in test; heavy debug logs slow large suites.
  • System tests. Put browser tests in their own job so they do not block unit and request specs. See flaky E2E tests if they are unreliable.

Split the suite

A single bundle exec rspec runs in one process. Two levers:

  1. Processes on one machine. The parallel_tests gem runs spec files across CPU cores with one database per process (parallel_rspec spec). Rails also has built-in parallelize(workers: :number_of_processors) for Minitest.
  2. Jobs across machines. Use a matrix and split files by recorded runtime. Knapsack balances by timings; parallel_tests can group by runtime from a log file.

A matrix using parallel_tests grouping (here -n 4 is the total group count):

strategy:
  fail-fast: false
  matrix:
    group: [1, 2, 3, 4]
steps:
  - uses: actions/checkout@v7
  - uses: ruby/setup-ruby@v1
    with:
      bundler-cache: true
  - run: bin/rails db:schema:load
  - run: bundle exec parallel_rspec --only-group ${{ matrix.group }} --group-by runtime -n 4 spec

Give parallel_tests a runtime log (tmp/parallel_runtime_rspec.log, which you can store as an artifact or cache) so groups balance. Without one it falls back to file size. More on the idea in test sharding in GitHub Actions, and the test shard calculator estimates a shard count.

Cores and Rails

Rails tests are mostly single-process Ruby, which uses one core, so a bigger machine only helps once you run parallel_tests or Minitest parallelize to use the cores. Then 8 vCPU can run 8 workers in one job instead of paying for four jobs, each with its own boot and install. We do not have a Rails row in our benchmarks, so we will not quote a speedup.

You can test your own suite on compiler.dev by changing one line to runs-on: [self-hosted, Linux, compiler-ci]. See pricing and the runner size picker. Related: the Rails template, GitHub Actions caching and slow CI.

FAQ

How do I cache gems in GitHub Actions for Rails?

Use ruby/setup-ruby with bundler-cache: true. It installs gems and caches them using your Gemfile.lock.

Is parallel_tests or a job matrix better for Rails?

Use parallel_tests to fill the cores of one machine and a matrix to spread across machines. Many teams combine them: a matrix of 3 to 4 jobs, each running several processes.

Why are my Rails tests flaky only in CI?

Common causes: Postgres not ready (add a health check), shared state between parallel processes (use one database per process), and time-dependent tests.

Should I run db:migrate or db:schema:load in CI?

Load the schema for speed. Run migrations in one separate check to make sure they still apply.

Run this workflow on compiler.dev 200 free minutes

Start free