Safely Upgrading a Legacy Rails Application

How a staged upgrade approach reduced risk when moving a production Ruby on Rails application across major framework versions.

Case study · Rails upgrade · Production system

Client identity, business details and proprietary code have been omitted. The sequence of versions, the kinds of issues encountered, and the reasoning behind the approach reflect the actual engagement.

Background

This application had been running in production for several years. It had a steady stream of real users, the kind of integrations a long-lived SaaS accumulates, and a codebase shaped by many hands over time. Nothing dramatic — just a mature Rails app doing useful work.

Automated test coverage was limited. Some parts of the codebase had reasonable specs; others had almost none. That alone meant any framework upgrade carried more risk than it would in a well-tested codebase, because the safety net you would normally lean on during a Rails upgrade — running the full suite and trusting it — was not fully available.

The starting version was Rails 6.1, and the goal was to move the application onto a current, supported Rails version. The natural temptation in that situation is to jump straight to the newest release. We chose not to.

The Upgrade Path

The upgrade was performed as a sequence of smaller, independently shippable steps:

  1. Rails 6.1 → latest stable Rails 6.1 patch release
  2. Rails 6.1 → Rails 7.0
  3. Rails 7.0 → latest stable Rails 7.0 patch release
  4. Rails 7.0 → Rails 7.1

Each step had the same structure: resolve gem compatibility, work through deprecation warnings, review framework defaults, verify the app boots cleanly, validate background jobs, look at deployment concerns, and only then move on. The pattern is unremarkable on its own — what mattered was applying it consistently rather than collapsing several steps into one.

Step 1: Latest patch release of Rails 6.1

Before changing the major version at all, the app was moved to the latest patch release of its current Rails line. This is the cheapest, lowest-risk change in any upgrade plan: same framework defaults, same APIs, mostly bug and security fixes. It also surfaces the gems that aren't keeping up with their own patch releases, which is useful information for the steps that follow.

Step 2: Rails 6.1 → Rails 7.0

This was the largest functional jump. Rails 7.0 introduced changes that touch areas almost every app cares about — Zeitwerk-only autoloading, changes around ActiveSupport::Cache, the new asset pipeline story, and a long list of new_framework_defaults entries. The point of doing this jump on its own, rather than bundled with a 7.1 jump, was to keep the diff and the review surface focused on one set of upstream changes.

A handful of gems needed to be updated, replaced, or pinned. A few initializers needed adjustments. The new framework defaults were not switched on wholesale — they were reviewed entry by entry and enabled deliberately, with the ability to defer any that were not safe to flip immediately.

Step 3: Latest patch release of Rails 7.0

Once the app was running on 7.0 in production and behaving well, moving to the latest 7.0 patch release was again a small, low-risk step. It also picked up small fixes that made the 7.1 jump cleaner. Doing this as its own step kept the next change focused on the 7.1 differences, not "everything that changed in 7.0.x plus everything that changed in 7.1."

Step 4: Rails 7.0 → Rails 7.1

By the time the 7.1 upgrade happened, most of the moving parts — gem versions, autoloading, framework defaults — had already been brought up to date. The remaining work was the genuine 7.0 → 7.1 surface: a smaller set of deprecations, a few new defaults to review, and a handful of gem version bumps. With the rest of the noise already cleared, this step was much easier to reason about than it would have been as part of one large jump.

Why Incremental, Not One Big Jump

The most common question on a project like this is whether all of that staging is really worth it. Going from 6.1 to 7.1 in a single branch is technically possible, and on a well-tested app it can be reasonable. On this codebase, with limited test coverage, it would have meant accepting all of the following at once:

The trade-off is honest: an incremental upgrade is more deploys, more coordination, and more elapsed time. On a production system with limited tests, that's usually the right trade.

Technical Challenges

Nothing here is exotic — most of these come up on almost any non-trivial Rails upgrade, and the value is in working through them deliberately rather than in any single trick.

Gem compatibility

Older apps tend to depend on at least a few gems whose own release cadence has slowed down. Some have new releases that support the target Rails version, some need to be replaced with a maintained alternative, and a small number can be carefully pinned for now and revisited later. Doing this work in stages, rather than across two major Rails versions at once, kept the Gemfile changes per step small enough to reason about.

Deprecation warnings

Each step was treated as the right time to address that version's deprecations — not the next one's. Deprecations from 6.1 were resolved before moving to 7.0; deprecations introduced in 7.0 were resolved before moving to 7.1. Carrying deprecation noise forward makes it harder to see when something genuinely new is wrong.

Framework defaults

config.load_defaults and the new_framework_defaults_*.rb initializers are easy to skim over and easy to break things with. Each entry was reviewed individually, with a conscious choice about whether to enable it at the same time as the upgrade, defer it, or roll it out on its own afterwards. This separates "running on Rails 7.x" from "running on Rails 7.x's defaults" — two things that don't have to ship together.

Initializers and configuration

Long-lived apps tend to accumulate initializers that quietly depend on internal Rails behavior. Some of these needed adjustments; a few were no longer necessary and could be removed. Doing this cleanup as part of the upgrade rather than separately reduced the chance of a config drifting back into a broken state.

Background jobs and Sidekiq

Background jobs are easy to leave for last and then regret. Sidekiq and the ActiveJob adapter were checked against each target Rails version, the job classes were exercised in a non-production environment, and the queue and retry behavior was watched on the first production deploy of each stage. Background-job regressions are often silent — no exception in the request cycle, just work that quietly does not happen — so they deserve explicit attention.

Ruby version

Ruby upgrades were treated as their own steps, sequenced alongside the Rails work rather than bundled into the same change. Conflating a Ruby upgrade with a Rails upgrade is a good way to spend a long time bisecting an issue that could have been caught in either layer independently.

Lessons

Some of these are obvious in hindsight and worth restating anyway:

Key Takeaways

For an engineering manager or CTO evaluating an upcoming Rails upgrade, the practical points worth carrying out of this:

If your Rails application is several versions behind, I help teams plan and execute upgrades while keeping production risk under control. Feel free to get in touch if you're evaluating an upcoming Rails upgrade.

← Back to all case studies

*****

Made With Love ❤️