Skip to content
Veldway
← WorkReference Design

Upgrading a production Rails app from 5.2 to 7.2 without a rewrite

Scenario: business-critical Rails app several versions behind

A complete worked design for a frequent Rails request: an app that still works but is stuck on versions past their security support.

The problem

Rails apps that run a business tend to fall behind quietly. Upgrades get deferred because each one looks risky, and the gap grows until the framework and Ruby version are out of security support, new gems refuse to install, and hosting platforms start retiring the old stack. At that point the usual proposals are a big-bang upgrade branch that drifts for months, or a full rewrite. Both stop feature work and both concentrate all the risk into one release.

Requirements

  • Reach a Rails and Ruby version with current security support
  • No long-lived upgrade branch: production keeps shipping features throughout
  • Every step deployable and reversible on its own
  • No customer-visible downtime during any deploy
  • Leave the team with tests and CI that make the next upgrade routine

The solution

Upgrade one minor version at a time on the main branch. Before touching versions, pin the app's critical behaviour with tests. Then run CI against both the current and the next Rails version (dual-boot) so compatibility fixes merge continuously while production stays on the current version. When the next version is green, flip production to it, watch, and only then start the next step.

Architecture

Rails 5.2Ruby 2.7 · todayRails 6.1step 1 · shippedRails 7.0step 2 · shippedRails 7.2step 3 · shippedCurrentRuby 3.3Test safety netcritical flows pinned firstDual-boot CIcurrent + next RailsDeploy + rollbackzero-downtime · per stepevery step ships to production before the next one starts

The app boots against two Gemfile locks: the one production uses and one for the next Rails version. CI runs the full suite against both on every pull request, so compatibility fixes land in small, reviewable changes on the main branch. Each version step ends with a production deploy behind a written rollback plan (revert the lock, redeploy). Deprecation warnings are collected in CI and treated as the to-do list for the following step. The Ruby upgrade is scheduled as its own step rather than bundled with a Rails jump, so a failure always has one cause.

Challenges & resolutions

Core flows such as signup, checkout, and billing have little or no test coverage, so there is no way to know whether an upgrade step broke them.

Resolution — The first step changes no versions at all. It adds request and system tests around the flows that make or move money, recorded against current behaviour, so every later step is checked against the same baseline.

Several gems are abandoned and have no release compatible with newer Rails.

Resolution — Each one gets a decision written down: upgrade to a maintained fork, replace with a maintained gem, or inline the small part the app actually uses. Replacements land before the Rails step that needs them, behind the existing tests.

Schema-changing migrations on large tables can lock writes during a deploy.

Resolution — Migrations are split into backward-compatible stages (add column, backfill in batches, switch reads, drop later), so old and new code can run side by side during the deploy window.

Why this design exists

"Our Rails app is stuck on an old version" is a frequent way outside Rails work starts. Rather than describe the approach in the abstract, this is the complete plan we would follow for an app in that position, including the parts that are harder than the diagram suggests.

Design notes

Tests come before versions. An upgrade is only as safe as the evidence that nothing broke. The first step adds tests around the flows that matter most to the business and changes nothing else. It is the least visible step and the one that makes the rest cheap.

Dual-boot beats the upgrade branch. A separate upgrade branch drifts away from main every day it lives, and merging it back becomes its own project. Running CI against both versions lets every compatibility fix merge into main as a small change while production still runs the old version.

One cause per failure. Rails jumps, Ruby jumps, and gem replacements are separate steps. When something goes wrong in production, there is only one change it can be, and rolling it back is a single, rehearsed action.

Stopping is allowed. Each step leaves the app working and better supported than before. If budget or priorities change halfway, the client keeps a working app on a newer version, not a half-finished branch.

What we'd adapt per client

The starting version, the gems in use, the hosting platform, and the amount of existing test coverage all vary, and the audit pins them down before any estimate. The step structure, the test-first rule, dual-boot CI, and the per-step rollback plan carry over unchanged, which is why we keep this as our reference design.

What the design guarantees

  • Designed so production is never more than one small step away from its last known-good version
  • Feature work continues during the upgrade because there is no long-running branch to merge
  • Each step can be stopped after it ships, leaving a working, supported app at every stage
  • The test suite and dual-boot CI remain afterwards, making the next upgrade a routine task

Have a similar problem?

Describe the problem in plain language — broken, slow, manual, or missing. We'll tell you honestly whether and how we can help.

  1. 01We reply within one business day
  2. 02A short call to understand the problem
  3. 03A written scope and fixed quote — no obligation