Most developers and testers have been a part of this story before:
- You work on your feature, commit new code, and push it to the repository.
- You pick up the next task on your list and begin working on it.
- Fifteen minutes later, you receive a notification from your continuous integration service saying your last commit caused a failure in your checks.
- Now you have to figure out what happened, commit a fix, and pray that it doesn’t break again.
Beyond the cost of context switching between tasks, there’s a good chance that your CI runs don’t run as fast as you’d like. All that time switching back-and-forth between tasks and waiting for your slow CI service to spin up and execute your tests adds up to a lot of wasted time.
This wasted time is something that newer versions of Ruby on Rails aim to resolve. One of the new and under-heralded features introduced in the recent release of Rails 8.1 is local continuous integration, or local CI. This functionality provides a new DSL to let you specify all the steps needed to check your app, from running tests to verifying code quality to doing security scans—everything you need to let you confidently deploy new updates of your app to production and do it fast.
What Local CI Solves
Most web apps these days use third-party services like GitHub Actions, CircleCI, and GitLab’s integrated CI/CD tools to run all the checks necessary before pushing out changes into the world. These continuous integration services cost very little to use, and many even offer free tiers, making it a no-brainer to use when they’re easily accessible for any project size.
However, most of these services require a lot of initial setup time to get working right, usually consisting of updating the configuration, pushing it to your repository, and waiting for the service to run through its steps. Moreover, the free and entry-level tiers offered by these services use virtualized environments that are often underpowered and slow, sometimes causing inconsistent results. It’s fairly common to find that these services are one of the primary bottlenecks in a team’s ability to ship.
The idea behind the local CI functionality in Rails 8.1 is to eliminate the bottleneck by having your local development system serve the same purpose of checking that your updates are ready to go. The new DSL is simple by design, making it easy to create the steps needed to run your checks. More importantly, the average software development system these days is decked out with high-powered multi-core CPUs, gigabytes of memory, and super-fast hard drives. We can harness these resources by running the same CI flow in a fraction of the time as a cloud-based CI service.
Setting up Local CI in a Rails App
Spinning up a new Rails 8.1 application will have the local CI functionality ready to go, so there’s nothing you need to do. But for existing Rails applications created before the latest release, you’ll have to get things set up on your codebase before you can take advantage of this new feature.
For this example, I’ll use a Ruby on Rails application called Airport Gap that you may have seen throughout multiple tutorials on this website. I created the Airport Gap app in 2019 using the latest version of Rails at the time, which was 6.0. Currently, it's running on Rails 8.0, so let’s update it to the latest version of Rails to have the new local CI functionality available.
Upgrade the app to Rails 8.1
The first step is to update Rails in the application’s Gemfile. As of this writing, the latest version of Rails is 8.1.1:
# In Gemfile:
gem "rails", "~> 8.1.1"
Running bundle install after this update gets the app already using Rails 8.1, but we also need to ensure that we have all the new configuration files and scripts provided in this new version.
Run the bin/rails app:update task
When upgrading a Rails app to a new dot version (e.g., Rails 8.0 to Rails 8.1), it’s recommended to run the bin/rails app:update task. This task goes through your app’s configuration to determine whether it needs to generate new scripts and configuration files, as well as update existing ones.
Depending on your app, it takes a couple of minutes to verify the diffs for existing files. If you run this task regularly any time you update a Rails app, most changes are minor or inconsequential, like updated punctuation in a comment. For the most part, it should be safe to keep your existing configuration files intact and let the update task create any missing files that Rails 8.1 now generates by default.
While this step can be a bit tedious, it’s critical to pay attention and not blindly accept new changes, as it can modify your app’s behavior in ways you didn’t intend.
Load framework defaults to the new version and fix any errors or deprecation warnings
For most Rails apps, going from Rails 8.0 to 8.1 is straightforward with minimal changes on the backend. However, you may have some dependencies in your Gemfile that haven’t been updated to work properly on Rails 8.1. If you have any errors or deprecation warnings when updating to Rails 8.1, make sure your dependencies are up to date and report any issues to the maintainers so they can fix them as soon as possible.
As with any app update, you should run any automated tests you have in your repo and also run the app locally to make sure things are working well. The Airport Gap app for this example is relatively simple, and most dependencies are already up to date, so the update only takes a few minutes to verify.
A final recommendation is to tell Rails to load the new version’s default settings. The bin/rails app:update task creates an initializer (config/initializers/new_framework_defaults_<version>.rb) containing a commented-out list of the new defaults that have changed in the new version of Rails.
The idea is for developers to read through the changes and uncomment each one individually to make sure the updates don’t break your app. Once you’ve confirmed these changes won’t affect any existing behavior, you can change Rails to use the framework defaults from Rails 8.1 by updating the config.load_defaults setting in config/application.rb:
# In config/application.rb:
config.load_defaults 8.1
Exploring the Local CI Configuration Files
After running the bin/rails app:update task, your Rails app will contain a few new files related to the new local CI feature: config/ci.rb and bin/ci.
Let’s first take a look at the bin/ci script:
require_relative "../config/boot"
require "active_support/continuous_integration"
CI = ActiveSupport::ContinuousIntegration
require_relative "../config/ci.rb"
bin/ci is a basic Ruby script that will load your application dependencies and also require the new continuous integration library called ActiveSupport::ContinuousIntegration that lets you use the DSL for defining and executing steps, which is the core of local CI. You likely won’t ever have to touch this script, and you’ll only use it to run the steps defined in the config/ci.rb .
Here’s the default config/ci.rb file generated by Rails:
# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end
This default configuration shows how the local CI DSL works, starting with the CI.run method. This block defines the start of a continuous integration run, letting you execute different steps and show the result of each. When all the steps defined in this block finish, it exits with an appropriate code depending on the results—a zero exit code if all steps pass or a non-zero exit code if there are any failures.
In addition to defining the start of the process, CI.run also sets the CI environment variable with a value of true during the block’s lifetime. This value lets you conditionally modify the behavior of the app during the test run, such as disabling logging or turning on features typically disabled in the development environment, like eager loading.
To define a step to execute in the continuous integration run, local CI uses the step method, which accepts a message to display and the command to execute for the CI run. The command can be a string (as shown in these examples) or multiple strings passed as individual arguments, such as "bin/setup", "--skip-server".
The difference between specifying the step command as a single string or multiple strings is that when passing multiple strings, the command is executed using Ruby’s system method and runs the command in a subshell directly in the operating system. Doing this can be helpful in certain scenarios, like if you want to run a command that needs to handle special characters in filenames. For most, defining the command as a single string works best.
The example that Rails 8.1 generates has three steps (plus an additional commented-out section that we’ll discuss later):
- The first
stepis the initial setup for the application on your local system. This step will run thebin/setupscript provided by Rails, which installs the app’s dependencies and sets up the local database. It uses the-skip-serverflag to bypass starting up the web server. - The second
stepruns Rubocop to make sure our styling conventions are in place. If you would like to learn more about Rubocop, see the article "Setting Up Rubocop for Legacy Ruby on Rails Projects." I highly recommend using it for your Rails projects, especially when working on a team with other developers. - The third
stepruns a security audit for Importmaps in our application. The Airport Gap application is an older Rails app that doesn’t use Importmaps, so I can remove the step in this CI run.
Let’s add a new step to cover running unit tests. The Airport Gap application uses RSpec for its test suite and runs using the bin/rails spec command:
CI.run do
step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop"
# Removed Importmap vulnerability audit
# Add step to run unit tests
step "Unit tests: RSpec", "bin/rails spec"
# Omitted optional section to discuss later
end
With these updates, we should have a decent first pass at running our continuous integration process in our local development system for the Airport Gap app.
Running Local CI
After setting up the initial local CI configuration for this app, let’s take it for a spin. To see it in action, open up a terminal and execute the bin/ci script mentioned earlier in this article. It goes through each step defined in the config/ci.rb configuration file, and for each one it displays if the command exited successfully, along with the time it took to execute:

In our first pass, we see the initial setup step and the unit tests passed without any issues, but the Rubocop step did fail, thus failing the local CI run. Seeing the output, I can tell it’s due to the newly generated files from the app:update task not following the styling rules set for this project. Having this step fail is a good thing, because I likely would have forgotten to check Rubocop before pushing these changes to the repository.
After fixing the Rubocop offenses, I can re-run local CI by executing the bin/ci script once again. This time, I see everything was successful:

This example demonstrates how simple it is to get local CI working for your Rails app, as well as the benefits of running these tests locally. I was able to detect a Rubocop issue and fix it in a fraction of the time it would have taken me when relying on a cloud-based CI service. The process takes less than 10 seconds in my development system, but the same steps take over two minutes on GitHub Actions. It might not seem like a huge difference, but the time saved across your development team can be massive over time.
Signing off Builds
Now that you know how to use local CI for Rails, let’s take a look at the commented-out section of the config/ci.rb configuration file:
CI.run do
# Omitted existing steps
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end
As shown in the message, this optional configuration is made to set the pass or fail status of a GitHub commit status after a CI run. Most teams use the status of the last commit in a branch to indicate whether it’s ready to merge or not and can configure their GitHub repository to block merging any pull requests with a failed status.
Using CI services like GitHub Actions sets the status automatically for you in a pull request. However, given that local CI runs on a developer’s machine, you’ll need a way to send the status after running your tests locally. The team at Basecamp created a GitHub CLI extension called gh-handoff that does this.
After running your tests, local CI will call the success? method that comes from the ActiveSupport::ContinuousIntegration library to see if all steps passed. If they did, a new step will run the gh signoff command. This command uses the information of your local repo to ping GitHub with a new status for the latest commit, indicating that a developer ran CI locally and it passed.
To see how it works, let’s update the local CI configuration to enable this signoff step by uncommenting the conditional check:
CI.run do
step 'Setup', 'bin/setup --skip-server'
step 'Style: Ruby', 'bin/rubocop'
step 'Unit Tests: RSpec', 'bin/rails spec'
if success?
step 'Signoff: All systems go. Ready for merge and deploy.', 'gh signoff'
else
failure 'Signoff: CI failed. Do not merge or deploy.', 'Fix the issues and try again.'
end
end
I’ll also need to install the gh-handoff extension on my local system. The extension requires having the GitHub CLI installed and set up already, which I won’t go through here. If you already have the CLI tool set up, installing the extension is simple with a single command:
gh extension install basecamp/gh-signoff
I’ve created a new pull request with a few changes to use as an example to test out the signoff process. Currently, there are no GitHub Actions workflows running for this repo, so we don’t know what the status of the app is at this time.
I’ll go back to my terminal and run CI locally using bin/ci, which will run all my checks. If they all pass, the step to run the gh signoff command is triggered, and it will send a status to the last commit made to that branch. If I check the pull request on GitHub, I should now see that I have signed off on this branch:

One important note here is that the signoff process in this example only works when the current Git branch is tracked with a remote branch on GitHub. Depending on your local Git configuration, pushing a branch to a remote repository may or may not automatically do this for you, so make sure to understand how tracking branches work.
Cases Where Local CI Won’t Help
By now, it’s evident that the local CI functionality can help speed up the feedback cycle for validating that a Rails app is working as intended. However, in the short time I’ve used it, it’s not well-suited for all scenarios. Here are a few use cases where you might be better off using a remote CI service.
Multi-architecture builds
Local CI runs happen only on the developer’s system, but you might also need to ensure your app works on a different system architecture than the one you’re developing in. For instance, you’re developing on a newer Mac system, which runs on the ARM architecture, but the production build runs on a Linux X64 server. Many third-party services provide different ways to run your CI steps using different architectures, which is difficult to replicate locally.
Complex builds
The example in this article shows the steps typically run through CI for a Rails app, such as running code style checks and automated tests. Local CI is perfect for these scenarios, since you’ll have everything set up on your local development environment. In some cases, CI runs are a little more involved, especially for end-to-end testing that requires you to spin up additional services, some that might be difficult to run locally.
Artifact generation
Depending on your team’s workflow, your CI builds might need to generate different artifacts, such as screenshots of specific tests or logs for observability and traceability. Your CI steps can generate these, but using local CI keeps them in the developer’s local environment. If you need to share different build artifacts across a team, using local CI will require some extra work that’s usually built into external CI services.
Wrap-Up
Local CI in Rails 8.1 is a nice addition that offers a simple and easy way to speed up your development workflow. Instead of dealing with waiting for slow feedback cycles when running CI on cloud-based services, you can get the results for them in a fraction of the time on your local development environment. It’s easy to get started, even on older Rails apps.
While local CI isn’t a one-size-fits-all solution to validating your systems, it works great on the typical Rails app development workflow. Give it a shot on your next feature branch and see if the faster feedback loop changes how you work. Chances are, you'll find yourself running bin/ci more often than you'd expect.