Rails Test Suites on GitHub Actions
Rails suites on GitHub Actions lose minutes to cold gem installs, per-worker schema loads, and serial runs. Workflow YAML, worker sizing, and real rates.
Last verified:
A Rails suite on GitHub Actions spends its minutes in four places: installing gems, loading the schema into a test database, precompiling assets for system tests, and running the tests themselves. Cache vendor/bundle on a Gemfile.lock key, prepare the schema once and let Rails clone it per worker, and put runs-on on a warp- label with enough cores to fan the suite out.
Overview
Rails ships parallel testing in the framework. parallelize(workers: :number_of_processors) in test/test_helper.rb forks one worker per core and gives each worker its own database, named after the primary test database with a worker index appended (Rails testing guide). The framework side is done; what decides wall clock is how many cores the runner has and how much of the job is spent before the first test runs.
WarpBuild runners register against your organization under warp- labels, and the Linux images carry the same tooling as GitHub-hosted runners, so Bundler, Rails, Minitest, RSpec, and Capybara run unchanged. Runners are ephemeral virtual machines, allocated fresh per job and destroyed at the end, which is also why the gem directory and the test database start empty every time. Labels, machine shapes, and per-minute rates are in the cloud runners documentation.
The rest of this page is the Rails specifics: a workflow with a database service and parallel workers, worker sizing against the Linux x64 ladder with rates applied, and the four bottlenecks that dominate a Rails job. For plain Ruby libraries and gems without ActiveRecord or asset compilation, Ruby builds on GitHub Actions is the shorter path.
Configuration
The workflow below runs a Rails suite on warp-ubuntu-latest-x64-8x with a Postgres service container, a bundler cache keyed on Gemfile.lock, one schema preparation step, and eight parallel workers.
name: rails-tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: warp-ubuntu-latest-x64-8x
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -h 127.0.0.1 -U app -d app_test"
--health-interval 5s
--health-timeout 5s
--health-retries 20
--health-start-period 10s
env:
RAILS_ENV: test
DATABASE_URL: postgres://app:[email protected]:5432/app_test
PARALLEL_WORKERS: "8"
RAILS_MAX_THREADS: "5"
steps:
- uses: actions/checkout@v5
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3.9"
- 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
uses: WarpBuilds/cache@v1
with:
path: vendor/bundle
key: ${{ runner.os }}-${{ runner.arch }}-gems-3.3.9-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gems-3.3.9-
- run: bundle install --jobs 4
- name: Prepare the test schema
run: bin/rails db:test:prepare
- name: Run the suite
run: bin/rails test test/Three details in that file carry the weight.
The bundler config step runs before the restore step. bundle config set --local path vendor/bundle moves the install target into the checkout, which is what makes a cached directory the same directory Bundler is about to write to (bundle config reference). Deployment mode then requires a committed Gemfile.lock that matches the Gemfile, so a pull request that edits one and forgets the other fails the job instead of resolving unreviewed versions on the runner. The key hashes Gemfile.lock and pins the Ruby version and architecture, because compiled native extensions are built for one platform and one Ruby ABI. The bundler gem caching answer covers the save step, bundle clean, and partial restores in full. WarpBuilds/cache@v1 takes the same path, key, and restore-keys inputs as actions/cache@v4; the drop-in setup-* forks are listed in the setup actions documentation.
bin/rails db:test:prepare runs once against the primary test database. Rails parallel testing then creates the per-worker databases during setup, so there is no loop over worker indexes in the workflow.
PARALLEL_WORKERS sets the worker count explicitly rather than leaving it to core detection, which keeps the number visible in the workflow file when you change runner size. The health check on the Postgres container is addressed at 127.0.0.1 rather than the Unix socket, because the official image runs its initialization scripts against a temporary server that does not accept TCP connections. The service containers guide covers that startup race and the flags around it.
Sizing
Rails parallelizes by process, so runner size sets parallelism directly and memory sets the ceiling on it.
| Runner label | vCPU | RAM | Storage | Price per minute | Workers to start with |
|---|---|---|---|---|---|
| warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | 4 |
| warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | 8 |
| warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | 16 |
Budget memory as workers plus services plus overhead, then take the first label that clears it. A booted Rails worker holds roughly 1 GB for a mid-sized application, a system test adds a headless browser process on top of that worker, Postgres holds about 1 GB plus a backend per connection, and the runner agent and Docker daemon take about 0.5 GB. Eight workers running system tests therefore want the 32 GB shape rather than the 16 GB one, even though eight workers fit the core count of a smaller machine on paper. Substitute your own numbers; the point is that services share the machine with the workers.
Two things cap the return on more cores. Rails splits tests across workers as they finish rather than by a fixed slice, so an uneven suite balances better than a file-sliced one, but a single 12 minute system test still pins the run to 12 minutes no matter how many workers idle behind it. And every added worker adds a schema clone at setup and a database backend at runtime, so past 16 workers the database is usually the constraint.
Linux ARM64 carries lower per-minute rates at the same shapes: $0.006, $0.012, and $0.024 for the 4x, 8x, and 16x sizes. Most gems with native extensions publish ARM64 builds, and the rest compile on the runner, so a trial branch is cheap to run.
Applying rates to a fixed suite makes the bill concrete. GitHub publishes its per-minute Actions rates in the GitHub Actions billing reference, checked on 2026-08-13; WarpBuild rates come from the table above.
| Setup | Shape | Rate per minute | 9 minute run | 600 runs per month |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.144 | $86.40 |
| GitHub-hosted 8-core larger runner | 8 vCPU, 32 GB | $0.022 | $0.198 | $118.80 |
warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price, GitHub list price checked on 2026-08-13. At the same shape and the same wall clock, that is $32.40 a month on this suite.
Bottlenecks
A cold vendor/bundle on every run. A Rails Gemfile with 250 gems resolves, downloads, and rebuilds before a single test runs, and native extensions in pg, nokogiri, and grpc compile whenever no precompiled platform gem matches. Run bundle lock --add-platform x86_64-linux aarch64-linux and commit the lockfile so Bundler picks precompiled gems where they exist, and keep the cache keyed on the lockfile so a compile happens once per dependency change.
Schema load repeated per worker. Each worker database gets the schema during parallel setup, so a wide schema multiplies. db/schema.rb loads faster than a structure.sql full of extensions and functions, and squashing years of migrations into the checked-in schema keeps that load short. The database migrations page covers the migration side of the same workflow.
Asset precompilation before system tests. rails assets:precompile runs on every job that boots the full application. The work is deterministic for a given set of sources, so cache public/assets and tmp/cache/assets keyed on app/assets, app/javascript, and the JavaScript lockfile, and keep tmp/cache/bootsnap in the same entry to cut the boot cost each worker pays.
A suite that never fans out. An application upgraded from an older Rails version often has no parallelize call in test/test_helper.rb, so bin/rails test runs one process on a machine with eight. Add the call, confirm the worker databases appear, and only then buy cores.
When a failure reproduces only inside GitHub Actions, the Action Debugger pauses the workflow and opens a shell on the live runner, which beats adding print statements to a 20 minute suite.
Proof
The rest is measurable in your own repository. Run the suite unchanged on the label you use today, then on warp-ubuntu-latest-x64-8x with PARALLEL_WORKERS matched to the core count, and compare job duration at P75 rather than a single run, since a single run hides queue time and cache-miss variance. The CI observability Jobs report shows duration, queue time, CPU, and memory per job, which is how you tell a suite that saturates eight workers from one where six sit blocked on Postgres.
Every cost number on this page comes from a published rate with a link and a checked-on date, and no build-time multiplier is claimed for your suite, because your parallel efficiency is a property of your tests. Measure it, then pick the label.
FAQ
How many parallel test workers should a Rails suite run?
Start with one worker per vCPU, so 4 on warp-ubuntu-latest-x64-4x, 8 on the 8x, and 16 on the 16x, then check memory before going higher. Budget roughly 1 GB per worker for a booted Rails process, more when system tests add a browser, plus the memory the service containers hold on the same machine.
Do I still need db:test:prepare when the schema has not changed?
Yes. The runner is a fresh virtual machine and the Postgres service container starts empty on every job. bin/rails db:test:prepare loads db/schema.rb into the primary test database, and Rails parallel testing clones that database once per worker during setup.
Why do parallel Rails tests fail with too many connections?
Each worker holds its own ActiveRecord connection pool, so the connection count is workers multiplied by the pool size in database.yml. The official Postgres image ships max_connections at 100, which 16 workers with a pool of 5 plus fixture and system-test connections can pass. Lower the pool, or raise max_connections through the service container command.
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.