GitHub Actions for Rails in 2026: A CI/CD Pipeline That Actually Works
Most Rails CI pipelines I inherit as a fractional CTO look the same: slow, flaky, and held together with duct tape. A 20-minute build that randomly fails on system tests. Developers pushing to main with crossed fingers.
Here’s the pipeline I deploy on every new Rails engagement in 2026. It runs in under 5 minutes, catches real problems, and deploys with confidence.
The Full Workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bundle exec rubocop --parallel
- run: bundle exec brakeman --no-pager
test:
runs-on: ubuntu-latest
needs: lint
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports: ["6379:6379"]
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
fail-fast: false
matrix:
ci_node: [0, 1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Setup database
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
RAILS_ENV: test
run: bin/rails db:setup
- name: Run tests (split)
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
REDIS_URL: redis://localhost:6379/0
RAILS_ENV: test
CI_NODE_TOTAL: 4
CI_NODE_INDEX: ${{ matrix.ci_node }}
run: |
bundle exec rails test $(
find test -name "*_test.rb" | sort | \
awk "NR % $CI_NODE_TOTAL == $CI_NODE_INDEX"
)
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy
run: |
# Your deployment command here
# kamal deploy, cap production deploy, etc.
echo "Deploying..."
This looks straightforward, but every section exists for a reason. Let me walk through the decisions.
Concurrency Control Saves Money
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
When you push three commits in quick succession, you don’t need three CI runs. This cancels in-progress runs for the same branch. On a busy team pushing 30+ PRs a day, this cuts your Actions bill by 30-40%.
Lint First, Test Second
The needs: lint dependency means tests don’t even start until linting passes. Why waste 4 parallel runners on a test suite when there’s a Brakeman warning that’ll block merge anyway?
Rubocop with --parallel uses all available cores. On GitHub’s runners, that’s typically 2 cores, which cuts lint time roughly in half.
Parallel Test Splitting Without Paid Tools
Most guides point you to Knapsack Pro or CircleCI’s test splitting. Those work great, but you can get 80% of the benefit for free:
find test -name "*_test.rb" | sort | \
awk "NR % $CI_NODE_TOTAL == $CI_NODE_INDEX"
This distributes test files round-robin across nodes. It’s not time-balanced like Knapsack, but sorting ensures the distribution is deterministic. With 4 nodes, you’ll typically go from 12 minutes to 3-4 minutes.
When to upgrade to Knapsack Pro: When your slowest node takes 2x longer than your fastest. That means your test file sizes are too uneven for round-robin.
Service Containers Done Right
A common mistake: not setting health checks on service containers. Without them, your tests start before Postgres is ready, and you get flaky connection errors.
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
GitHub Actions waits for all health checks to pass before running steps. This eliminates an entire class of “works locally, fails in CI” bugs.
The fail-fast: false Decision
strategy:
fail-fast: false
By default, GitHub cancels all matrix jobs when one fails. That sounds efficient, but it’s terrible for debugging. When node 2 fails but node 0 gets cancelled, you have no idea if you have one failing test or twenty.
Set fail-fast: false. Let all nodes complete. Know the full scope of the damage.
Caching That Actually Works
The ruby/setup-ruby action with bundler-cache: true handles gem caching automatically. Don’t roll your own cache keys for bundler — the official action does it better and handles cache invalidation on Gemfile.lock changes.
For Node.js assets (if you’re running assets:precompile), add:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
- run: yarn install --frozen-lockfile
System Tests: Run Them Separately
If you have system tests (Capybara + headless Chrome), run them in a separate job:
system-test:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
- run: yarn install --frozen-lockfile
- name: System tests
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
RAILS_ENV: test
run: |
bin/rails db:setup
bin/rails test:system
- uses: actions/upload-artifact@v4
if: failure()
with:
name: screenshots
path: tmp/screenshots/
The upload-artifact on failure is crucial. When a system test fails, you get the screenshot. Without it, you’re debugging blind.
Branch Protection Rules
A pipeline is only as good as its enforcement. In your repo settings:
- Require status checks to pass — select the
lintandtestjobs - Require branches to be up to date — prevents merging stale branches
- Require linear history — squash or rebase, never merge commits
Your pipeline should also include zero-downtime migration checks as part of the deploy step — running strong_migrations in CI catches dangerous migrations before they reach production.
This prevents the “well it passed on my branch” problem where two PRs each pass individually but break when combined.
Secrets Management
Don’t use repository secrets for environment-specific config. Use GitHub Environments:
deploy:
environment: production
steps:
- run: kamal deploy
Environments give you:
- Required reviewers — someone must approve production deploys
- Wait timers — automatic delay before deploy starts
- Deployment logs — who deployed what, when
What This Gets You
With this setup, a typical PR cycle looks like:
- Push → Lint runs (30 seconds)
- Lint passes → 4 test nodes start (3-4 minutes)
- All green → System tests run (2-3 minutes)
- Merge → Auto-deploy triggers
Total time from push to deployed: under 8 minutes. That’s fast enough that developers don’t context-switch while waiting.
Combine this with feature flags for risky changes, and your deployment confidence goes through the roof — even on a Friday afternoon.
The pipeline is simple on purpose. Every clever optimization I’ve removed from CI pipelines has made them more reliable. Boring CI is good CI.
Frequently Asked Questions
How do I speed up a slow Rails CI pipeline?
Start with parallel test splitting — distribute test files across 4 matrix nodes using round-robin. Add bundler-cache: true for gem caching and run linting before tests so you fail fast on style issues. These three changes typically cut a 15-minute pipeline to under 5 minutes. If it’s still slow, profile your test suite for slow individual tests.
Should I use GitHub Actions or CircleCI for Rails?
GitHub Actions is the better default for most teams in 2026. It’s deeply integrated with GitHub (branch protection, environments, deployments), the free tier is generous, and the Actions marketplace covers most needs. CircleCI still has advantages in test splitting intelligence (Knapsack Pro integration) and Docker layer caching, but GitHub Actions closes that gap every year.
How do I handle flaky tests in CI?
First, mark flaky tests with a tag and track them. Don’t just re-run the pipeline — that hides the problem. Common causes: time-dependent assertions, shared database state between tests, and system tests that don’t wait for async operations. Use fail-fast: false so you see all failures, not just the first one. Fix the root cause rather than adding retries.
Is it safe to auto-deploy from CI on merge to main?
Yes, with safeguards. Require passing CI status checks, enforce branch protection rules, and use GitHub Environments with required reviewers for production deploys. Add a wait timer if you want a cooldown period. This setup means every merge is intentional, tested, and reviewed before it reaches production.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation