The Faster vs. Safer Approach to Dual-Booting Legacy Rails Apps
In 1869, the Transcontinental Railroad was completed with the driving of the Golden Spike. To achieve this monumental task quickly during the preceding winter, the Union Pacific railroad laid tracks directly on the ice across the Missouri River. It was a fast approach that allowed supply trains to cross immediately. However, when spring arrived and the ice melted, those temporary tracks vanished into the river. The engineers then had to build a proper, permanent steel bridge — a much slower, but fundamentally safer, approach.
When we modernize a legacy Ruby on Rails application, we face a similar choice in our infrastructure. Dual-booting — running the application on both the current and the target version of Rails simultaneously — has become the standard technique for executing long-term upgrades. Yet, there is more than one way to implement this strategy. There are two major approaches to dual-booting a legacy Rails application; depending on the particular circumstances you find yourself in, one of them may be more useful than the other.
The first is conditional logic with floating dependencies—this is the faster approach. The second option is to maintain a secondary lockfile using a plugin like bootboot—this is the safer approach. Generally speaking, the first option is simpler to set up initially, and may make more sense if the upgrade is expected to be brief. The second option, though, will often make more sense for complex, long-running migrations that require deterministic builds over time.
The Core Challenge of Dual Dependencies
Before we get into that, though, let us establish what dual-booting attempts to solve. A Ruby and Rails upgrade is rarely a brief task for a large monolith. It can take months of work to resolve deprecations, update third-party gems, and rewrite incompatible code. During this time, the rest of the engineering team must continue delivering features.
Dual-booting allows us to maintain two sets of dependencies. The main branch runs on the older, stable version of Rails, while a parallel CI job tests every new commit against the newer version. By way of a memory aide, you can think of your Gemfile as “my application’s requirements” and the Gemfile.lock as “how Bundler interpreted those requirements for our system.”
The technical challenge, however, lies in how we manage that Gemfile.lock. Bundler, by design, expects a single lockfile to guarantee that all environments use the exact same gem versions.
The Faster Approach: Environment Variables and Floating Dependencies
The most straightforward way to implement dual-booting is by introducing conditional logic directly into the Gemfile based on an environment variable.
In this setup, we might configure our CI pipeline to ignore the lockfile for the next version. We could do this by running an update command during the CI run:
```bash $ DEPENDENCIES_NEXT=1 bundle update ```
Alternatively, we might maintain a completely separate Gemfile.next file that is manually updated.
This is the faster approach. We can configure this logic in an afternoon. For a small application with a dedicated team executing a Ruby on Rails upgrade over a few weeks, this approach might be sufficient.
There is a significant trade-off, though. Ruby dependencies, strictly speaking, are not actually locked unless they are in the Gemfile.lock—and without a locked next version synchronized with our daily feature work, we expose the project to dependency drift. If a third-party gem releases a minor update that breaks compatibility, our Rails 8.0 CI build will fail unpredictably. We might waste hours debugging whether a failure was caused by new feature code, a Rails incompatibility, or a floating dependency that updated itself in the background.
The Safer Approach: Deterministic Builds with Bootboot
If the faster approach is laying tracks on the ice, the safer approach is building the steel bridge. For large legacy Rails apps, we need a mechanism that guarantees deterministic builds for both environments.
This is where the bootboot plugin for Bundler becomes invaluable. Developed to manage massive Rails upgrades at scale, bootboot allows Bundler to maintain a secondary lockfile — typically named Gemfile.next.lock — while keeping your primary Gemfile.lock untouched.
To implement this, we install the plugin and configure our Gemfile:
if ENV[‘DEPENDENCIES_NEXT’]
enable_dual_booting
gem ‘rails’, ’> 8.0.0’
else
gem ‘rails’, ’> 7.2.0’
end
<p>
Let’s see how this works in practice. Before we run any commands, our directory looks like this:
</p>
```bash
$ ls -1 Gemfile*
Gemfile
Gemfile.lock
Once we have configured our Gemfile with the bootboot plugin, we can run our standard installation command:
When we run this command, bootboot synchronizes both lockfiles. We can verify this by checking our directory contents again:
As we can see, bootboot has generated a secondary lockfile specifically for the target Rails version. You also may notice that the original Gemfile.lock is present and untouched; it remains stable while we iterate on our next version. One may wonder: if we ran bundle install without DEPENDENCIES_NEXT=1, how did bootboot know to generate the next lockfile? The answer is straightforward: the plugin intercepts the installation process and automatically evaluates both states to keep the lockfiles in sync.
From this point forward, anytime a developer adds a new dependency for a feature and runs bundle install, that exact version is locked into both the current and the next environments simultaneously.
To run tests or boot the application using the newer Rails dependencies, we pass the environment variable we defined:
```bash $ DEPENDENCIES_NEXT=1 bundle exec rspec ```
Note the use of bundle exec; we could have simply used rspec if it was globally installed, but that won’t necessarily load the correct gem versions for our next environment—especially when relying on a secondary lockfile from the plugin.
Of course, this safety comes with a cost. The initial setup requires us to resolve all version conflicts between the two lockfiles immediately. If a gem works with Rails 7.2 but is incompatible with Rails 8.0, Bundler will refuse to resolve the dependencies. We cannot defer these conflicts; we must find a gem version that satisfies both environments, or conditionally load different versions of the gem.
For example, if some_legacy_gem only works on Rails 7.2, and we need some_newer_gem for Rails 8.0, we would modify our Gemfile like this:
This ensures both lockfiles can resolve successfully without conflict. Naturally, this requires significantly more upfront engineering effort than the faster approach.
Navigating Trade-offs in Technical Debt Remediation
When executing technical debt remediation, we must balance momentum with stability. The faster approach provides immediate feedback on how far the application is from booting in the next version. The safer approach ensures that once a test passes in the next version, it stays passing.
Generally speaking, the safer approach is the preferred method for any organization relying on a Rails upgrade service or managing a migration that will span more than a month. Deterministic builds eliminate the “works on my machine” class of errors that often derail complex upgrade projects.
By accepting the upfront friction of managing two synchronized lockfiles, we provide our engineering team with a reliable, predictable foundation. This ensures that when the time comes for the final cutover, the transition will be uneventful — exactly as an infrastructure upgrade should be.
Sponsored by Durable Programming
Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.
Hire Durable Programming