March 9, 2026 · 14 min read
GitHub Actions CI/CD for Rails SaaS: The Complete 2026 Setup Guide
Jeronim Morina
Founder, Omaship
Most Rails SaaS projects start with manual deploys. You run the tests locally, push to main, SSH into the server, and pull. It works until it does not -- and it stops working exactly when the stakes get high: onboarding your first paying customer, pushing a hotfix at midnight, or handing the codebase to a second developer who has never seen your deploy script.
CI/CD is not about tooling for tooling's sake. It is about building a system that catches mistakes before your users do and deploys code without you being the bottleneck. GitHub Actions is the obvious choice for Rails projects in 2026: it is free for public repos, generous for private ones, and lives where your code already is.
This guide walks through the complete CI/CD pipeline for a Rails 8 SaaS: tests, linting, security scanning, deployment with Kamal, preview deploys per PR, secrets management, and caching. Every YAML example is taken from production use. No theoretical setups.
Why CI/CD matters for SaaS (ship fast, ship safely)
The founders who ship fastest are not the ones who skip testing. They are the ones who automated their testing so completely that shipping is a single merge. CI/CD gives you three things that manual workflows never will:
- Confidence in every deploy. When tests, linting, and security scans run automatically on every push, you stop second-guessing whether that "small change" broke something. The pipeline tells you.
- Speed without recklessness. A well-cached CI pipeline runs in under two minutes. That is faster than manually running tests, and it catches things you would forget to check: dependency vulnerabilities, code style violations, security issues.
- Exit readiness. Acquirers look at your CI/CD pipeline during due diligence. A green pipeline with test coverage, security scanning, and automated deployments signals a mature codebase. Manual deploy scripts signal risk.
The minimal GitHub Actions workflow for Rails 8
Start with the simplest useful pipeline: run tests, lint the code, scan for security issues. Everything else is an optimization on top of this foundation.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Run tests
run: bundle exec rails test
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Rubocop
run: bundle exec rubocop
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Brakeman
run: bundle exec brakeman --no-pager
- name: Bundle Audit
run: bundle exec bundle-audit check --update
Three parallel jobs. Each one installs Ruby and gems independently (with caching), then runs a single focused task. If any job fails, the pull request stays blocked. This is your safety net.
Notice what is missing: no database service container. Rails 8 defaults to SQLite, and that changes everything about CI setup.
Running Rails tests with SQLite in CI
If you are using PostgreSQL, your CI workflow needs a services block to spin up a Postgres container, wait for it to be ready, set up the database, and manage connection strings. That is 15-20 lines of YAML and a source of flaky failures when the service container takes too long to start.
With SQLite, your test step is:
- name: Prepare database
run: bundle exec rails db:prepare
- name: Run tests
run: bundle exec rails test
That is it. No service containers. No health checks. No connection string environment variables. SQLite creates the database file on disk, tests run against it, and the file is discarded when the job finishes. Tests run faster because there is no network round-trip to a database server -- everything is local file I/O.
For most SaaS applications, SQLite in CI is not a compromise. It is a simplification that removes an entire class of CI failures. If you are running Solid Queue, Solid Cache, and Solid Cable (the Rails 8 "Solid Trifecta"), everything runs against SQLite in both development and CI.
Brakeman: catching security issues before they ship
Brakeman is a static analysis tool that scans your Rails code for security vulnerabilities: SQL injection, cross-site scripting, mass assignment, unsafe redirects, and more. It does not execute your code -- it analyzes the AST, which makes it fast and safe to run in CI.
- name: Brakeman security scan
run: bundle exec brakeman --no-pager --ensure-latest
The --ensure-latest flag warns you if a newer version of Brakeman exists, which matters because new vulnerability checks are added regularly. The --no-pager flag ensures output goes directly to the CI log without waiting for interactive input.
Two practical notes:
- False positives happen. Brakeman sometimes flags code that is actually safe. Use a
config/brakeman.ignorefile to suppress specific warnings you have reviewed and determined to be safe. Document why each warning is ignored. - Do not skip it because of false positives. Every real vulnerability it catches in CI is one that did not make it to production. That trade-off is worth the occasional five-minute false positive review.
Bundle audit: dependency vulnerabilities
Your code might be secure, but your dependencies might not be. bundler-audit checks your Gemfile.lock against a database of known vulnerabilities in Ruby gems.
- name: Bundle audit
run: bundle exec bundle-audit check --update
The --update flag fetches the latest advisory database before checking. Without it, you are scanning against a stale list and might miss recently disclosed vulnerabilities.
When bundle-audit flags a gem, you have three options: update the gem (ideal), find a patched version that is compatible with your other dependencies (sometimes necessary), or acknowledge the risk if the vulnerability does not apply to your usage (document this in your audit ignore file).
Rubocop in CI: consistent code without arguments
Rubocop enforces code style and catches common mistakes. Running it in CI means code style is enforced automatically -- no more pull request comments about indentation or trailing whitespace.
- name: Rubocop
run: bundle exec rubocop --parallel
The --parallel flag uses multiple cores to speed up linting on larger codebases. For a typical SaaS, Rubocop runs in under 10 seconds.
A practical tip: run Rubocop locally before pushing. A pre-commit hook that runs Rubocop on staged files catches style issues in under a second, saving you the round-trip of pushing, waiting for CI, and pushing again. Lefthook makes this trivial to set up.
Automated deployment with Kamal from GitHub Actions
Kamal is the Rails-native deployment tool. It builds your Docker image, pushes it to a registry, and deploys it to your servers over SSH with zero-downtime rolling restarts. Running Kamal from GitHub Actions means every merge to main triggers a production deploy automatically.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
ci:
uses: ./.github/workflows/ci.yml
deploy:
needs: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Deploy with Kamal
env:
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: bundle exec kamal deploy
Key details in this workflow:
- The
concurrencyblock prevents parallel deploys. If two commits land on main in quick succession, the second deploy waits for the first to finish instead of racing it. - The
needs: cidependency ensures the full CI suite passes before deploying. A broken build never reaches production. - SSH agent forwarding gives Kamal access to your servers without storing SSH keys on disk in the runner.
cancel-in-progress: falseis critical. You never want to cancel a deploy midway -- that can leave your servers in an inconsistent state.
Preview deploys per pull request
Preview deploys spin up a temporary environment for each pull request, so you can test changes in a production-like setting before merging. This is not just for frontend changes -- it catches environment-specific issues, migration problems, and integration bugs that local development misses.
# .github/workflows/preview.yml
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
preview:
if: "!contains(github.event.pull_request.title, '[skip preview]')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Deploy preview
env:
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: bundle exec kamal deploy --destination=preview-pr-${{ github.event.pull_request.number }}
The [skip preview] escape hatch in the PR title lets you bypass preview deploys for documentation changes, dependency updates, or other PRs where a full preview is overkill. The preview environment is automatically cleaned up when the PR is closed.
Managing secrets and credentials
GitHub Actions secrets are encrypted at rest and never exposed in logs. But how you structure them matters.
- Use repository secrets for deployment credentials:
DEPLOY_SSH_KEY,KAMAL_REGISTRY_PASSWORD,RAILS_MASTER_KEY. These are scoped to the repository and accessible to all workflows. - Use environment secrets for stage-specific values. Create "production" and "staging" environments in GitHub settings, each with their own secrets. This prevents accidentally deploying to production with staging credentials.
- Never echo secrets in CI. GitHub redacts known secrets from logs, but derived values (like a URL containing a secret token) are not redacted. Treat CI logs as semi-public.
- Rotate secrets regularly. SSH keys and registry passwords should be rotated at least annually. Set a calendar reminder -- this is the kind of task that falls through the cracks until it causes an incident.
For Rails credentials specifically, the RAILS_MASTER_KEY secret is the only key you need in CI. Your config/credentials.yml.enc file is committed to git (encrypted), and the master key decrypts it at deploy time. Do not store individual API keys as GitHub secrets when they belong in Rails credentials.
Caching strategies for fast CI
A CI pipeline that takes 10 minutes is a pipeline developers learn to ignore. Caching is how you keep it under two minutes.
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # caches gems based on Gemfile.lock hash
- uses: actions/cache@v4
with:
path: |
public/assets
tmp/cache/assets
key: assets-${{ runner.os }}-${{ hashFiles('app/assets/**', 'app/javascript/**') }}
restore-keys: |
assets-${{ runner.os }}-
Two layers of caching matter:
- Gem caching is handled by
ruby/setup-rubywithbundler-cache: true. It hashes yourGemfile.lockand restores the entire gem installation directory. This alone saves 30-60 seconds per job. - Asset caching stores compiled assets between runs. If your CSS and JavaScript have not changed, there is no reason to recompile them. The
restore-keysfallback means a partial cache hit still saves time, even if some assets changed.
The ruby/setup-ruby action reads your .ruby-version file automatically. You do not need to specify the Ruby version in the workflow -- it stays in sync with your development environment without duplication.
The complete workflow file (annotated)
Here is a production-grade CI/CD workflow that combines everything above into a single, maintainable file:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# Cancel redundant runs on the same PR
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Prepare database
run: bundle exec rails db:prepare
- name: Run tests
run: bundle exec rails test
- name: Run system tests
run: bundle exec rails test:system
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Rubocop
run: bundle exec rubocop --parallel
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Brakeman
run: bundle exec brakeman --no-pager --ensure-latest
- name: Bundle Audit
run: bundle exec bundle-audit check --update
deploy:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [test, lint, security]
runs-on: ubuntu-latest
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Deploy with Kamal
env:
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: bundle exec kamal deploy
This single file gives you:
- Parallel test, lint, and security jobs on every push and PR
- Automatic deploy to production on merge to main, only after all checks pass
- Concurrency controls that cancel redundant CI runs but never cancel in-progress deploys
- Gem caching for fast installs across all jobs
- SQLite-based tests with no external service dependencies
The entire pipeline runs in under two minutes for a typical Rails SaaS. Tests take 30-40 seconds, Rubocop takes 5-10 seconds, and security scans take 10-15 seconds. Since they run in parallel, your wall-clock time is determined by the slowest job, not the sum.
Local CI first, GitHub Actions as a safety net
Here is an opinionated take: your primary CI should run locally, not in the cloud. A local CI script that runs tests, linting, and security scans before every push means you catch problems instantly, without waiting for a remote runner to pick up your job.
# bin/ci
#!/usr/bin/env ruby
require_relative "../config/ci"
# config/ci.rb
CI.run do
step "Tests", "bundle exec rails test"
step "System tests", "bundle exec rails test:system"
step "Rubocop", "bundle exec rubocop --parallel"
step "Brakeman", "bundle exec brakeman --no-pager -q"
step "Bundle Audit", "bundle exec bundle-audit check --update"
step "Zeitwerk", "bundle exec rails zeitwerk:check"
end
Combined with git hooks (Lefthook is excellent for this), your CI runs on every commit and every push. GitHub Actions becomes the safety net that catches anything the local CI missed -- which should be almost nothing if both run the same checks.
This approach has a practical benefit: it keeps CI fast. When CI only runs in the cloud, there is no pressure to keep it under a minute because the feedback loop is already slow. When CI runs locally, every second counts, and you naturally optimize your test suite and tooling to be fast.
Common pitfalls and how to avoid them
- Flaky tests in CI. A test that passes locally but fails in CI (or vice versa) is almost always a time dependency, file ordering issue, or environment assumption. Fix these immediately. A flaky test suite teaches developers to re-run pipelines until they pass, which defeats the purpose of CI entirely.
- Overly broad triggers. Running the full CI suite on documentation-only changes wastes compute. Use path filters:
paths-ignore: ['docs/**', '*.md']to skip CI for non-code changes. - Secrets in PR workflows. Pull request workflows from forks do not have access to repository secrets by default. This is a security feature. Do not work around it by making secrets available to fork PRs.
- Ignoring security scan results. A common pattern: Brakeman flags a warning, the developer adds it to the ignore file without review, and the vulnerability ships. Every ignored warning should have a comment explaining why it is safe.
How Omaship handles all of this automatically
Every Omaship project ships with a production-grade CI/CD pipeline already configured. Not a template you have to customize -- a working pipeline that runs on your first commit:
- Local CI via
bin/ci. Tests, Rubocop, Brakeman, bundle-audit, and Zeitwerk checks in a single command. Runs in under a minute on a typical machine. - Git hooks via Lefthook. Pre-commit runs Rubocop on staged files (sub-second). Pre-push runs the full CI suite. Installed automatically by
bin/setup. - GitHub Actions as safety net. The same checks that run locally also run in CI on every push to main. Your pipeline is your safety net, not your primary feedback loop.
- Kamal deployment built in.
deploy.ymland Kamal configuration are ready. Add your server IP and secrets, and you have zero-downtime deployments on every merge. - Preview deploys per PR. The omaship_preview engine deploys a preview environment for every PR and cleans it up automatically when the PR is closed or merged. Add
[skip preview]to the PR title for small fixes. - Architecture enforcement. ast-grep rules in CI catch structural violations (wrong namespaces, forbidden patterns) that linters miss. The rules are checked in pre-commit hooks and in CI.
- Security scanning from day one. Brakeman and bundle-audit are in the Gemfile, configured in CI, and run on every push. No setup required.
The goal is not to give you a template to fill in. The goal is to give you a working pipeline that you never have to think about. You write code, push it, and the system handles the rest: checking, building, deploying, and cleaning up.
CI/CD that works from your first commit
Omaship ships with local CI, GitHub Actions workflows, Kamal deployment, preview deploys, and security scanning -- all configured and ready to run. Stop building pipelines and start building your product.