← All engineering notes
ENGINEERING JOURNAL / FIELD NOTES

From Rails 6 to Rails 8: A Practical Legacy Modernization Strategy

*A migration plan for 200+ vulnerability findings, 1,000+ technical debt items, and a 95% unit test coverage target.*

PUBLISHED / Engineering Note · BY PHIL.ON
rails-6-to-8

A legacy Rails application can continue serving customers while becoming increasingly difficult to change. Dependency upgrades stop resolving. Business rules hide inside callbacks. A small feature requires manual checks across unrelated parts of the system.

When the backlog includes 200+ vulnerability findings and more than 1,000 technical debt items, modernization needs a clear sequence: understand the existing behavior, remove compatibility blockers, upgrade in controlled steps, and verify the system that actually gets deployed.

This guide develops that sequence with a 95% unit test line coverage target. These figures describe the scope and goals of the approach, rather than measured results from a completed project. The code examples are illustrative and have not been executed against a production application.

1. Define success before changing dependencies

Three large numbers can conceal three different measurement problems.

First, 200 vulnerability findings may include duplicate scanner reports, advisory aliases, and the same vulnerable package in several images. Track unique vulnerabilities and affected deployment artifacts separately. Call them 200 CVEs only when the deduplicated inventory supports that statement.

Second, 1,000 technical debt items need consistent closure rules. Removing an unused dependency, correcting a dangerous callback, and disabling a lint rule have different consequences. Report fixes, removed code, verified false positives, and accepted debt separately.

Third, 95% unit test line coverage needs a declared code scope and a unit-only test run. A percentage produced by merging request, integration, and system tests measures combined-suite coverage.

Success means being able to explain what changed, how it was verified, and which risks remain.

2. Establish a reproducible baseline

Start from a clean checkout in an isolated development or CI environment. Record the runtime and framework versions before updating anything:

ruby --version
bundle --version
bin/rails about

Keep the current lockfiles and identify the images used by web processes, workers, and scheduled jobs. Include database adapters, native extensions, JavaScript dependencies, and external file-processing tools in the inventory.

Next, identify the business workflows whose failure would be expensive. For a subscription application, examples include granting access after payment, processing cancellation, rejecting access across tenants, and handling duplicate payment notifications.

Write characterization tests around the intended behavior before refactoring it. Where the current behavior is a known defect, record a separate correction and expectation rather than preserving the defect indefinitely.

Also capture request latency, error rate, database time, worker memory, and queue age under a representative workload. These measurements help detect regressions outside the test suite.

3. Upgrade through compatibility checkpoints

For an application whose existing behavior remains valuable, I recommend an incremental upgrade within the current codebase. Each checkpoint gives the team a smaller failure surface and a useful comparison with the previous state.

A rewrite can be justified when essential requirements cannot fit the existing architecture, but its budget must include behavior discovery, data migration, and operating both systems during the transition.

As checked on November 14, 2026, the latest Rails release identified in the official announcements is Rails 8.1.4.1. Recheck the release announcement when applying this guide.

For a Rails 6.0 application, work through Rails 6.1, 7.0, 7.1, 7.2, 8.0, and 8.1, using the latest available patch at each checkpoint. Start at the relevant point if the application already runs a later series. The Rails team recommends this incremental upgrade process.

These are build and validation checkpoints. Unsupported intermediate releases do not automatically become production destinations.

Update Ruby where compatibility allows

Rails 8.0 and 8.1 require Ruby 3.2 or newer. Ruby 3.2 itself reached end of life on April 1, 2026, so minimum compatibility does not establish a suitable maintenance target. The Ruby maintenance table lists Ruby 3.4 under normal maintenance at the research date.

Select a maintained, patched Ruby release that the application's gems and native extensions support. Separate runtime changes from Rails changes where their compatibility ranges overlap.

Keep dependency changes reviewable

The following Gemfile entry illustrates the final Rails 8.1 checkpoint, after the earlier transitions and runtime prerequisites have been validated:

# Gemfile — final checkpoint, not the first change on Rails 6
gem "rails", "8.1.4.1"

From the application root, resolve that change and inspect the configuration updates:

bundle update rails
bin/rails app:update
git diff -- Gemfile Gemfile.lock config

app:update is interactive and can modify configuration files. Review its proposed changes against custom initializers, deployment settings, logging, and session configuration. Avoid automatically accepting every overwrite.

Follow each checkpoint's upgrade notes and validate its behavior before advancing. Include relevant Rails JavaScript packages in the compatibility review. Keep unrelated dependency updates in separate changes where practical.

4. Check the behavior that a version number cannot describe

Installing a newer Rails version and adopting all of its defaults are separate changes. Use the framework-default migration process to validate changes gradually, then advance config.load_defaults when the application is ready.

Retain compatibility settings only while the target version supports them and they do not undermine an applicable security fix. Assign an owner and removal condition to each temporary setting.

Validate autoloading

Rails 7 requires Zeitwerk autoloading. A Rails 6 application that retained the classic autoloader must address that boundary before advancing.

Zeitwerk expects file paths to correspond to constant names. Incorrect naming can remain hidden until a rarely used code path loads the affected file. Run the documented Zeitwerk check in the test environment:

Code
RAILS_ENV=test bin/rails zeitwerk:check

Review custom paths too: the task checks eager-loaded code. Add a production-like boot check with isolated resources so that deployment-specific initialization also receives coverage.

Verify shared-state compatibility

During a rolling deployment, old and new processes may share caches, sessions, job queues, and database records. Test what happens when one version reads state written by the other.

For example, Rails 6.1 cannot read the newer Rails 7.0 cache format. The versioned upgrade guide describes updating readers before switching the writer format.

Verify the rollback direction as well. Once the new version writes incompatible state, redeploying the previous binary may no longer restore service. Durable job payloads and sessions need their own strategy; clearing a disposable cache cannot repair them.

5. Turn security findings into verified remediation

A Rails upgrade can address framework vulnerabilities while leaving vulnerable gems, JavaScript packages, native libraries, or operating system packages in the deployed image.

Maintain a remediation ledger containing the advisory, affected component, dependency path, deployed artifact, relevant configuration, fix, and validation evidence.

With the appropriate tools installed in the project's tooling bundle, run separate dependency and source checks:

# Match locked Ruby dependencies against known advisories.
bundle exec bundle-audit check --update

# Analyze Rails application source for potential security issues.
bundle exec brakeman

bundler-audit and Brakeman inspect different surfaces. Brakeman warnings are not automatically dependency CVEs, and both reports need context and review.

Prioritize severe findings on reachable paths that process untrusted input. Prefer a fixed release, removal of an unused dependency, or replacement of an abandoned component. A finding judged difficult to exploit still needs an explicit disposition; reachability does not patch an installed package.

Rebuild and scan the final replacement artifacts, verify deployment, and retire vulnerable artifacts from routine rollback use. Preserve the scanner version, advisory database revision, commit, and scan date.

Report accepted risks and false positives separately from remediated findings. A clean scan establishes a result for the tools, scope, and advisory data used at that time.

6. Reduce technical debt around meaningful boundaries

Start with upgrade blockers, correctness risks, and code that changes frequently. Apply mechanical cleanup in separate, bounded changes so reviewers can still identify behavior changes.

Testing software boundaries


Conceptual illustration: reducing coupling while keeping the existing system supported.

Consider a subscription model whose save callbacks grant access, create an invoice, and send email. Extracting those calls into a service object helps only if the resulting boundary makes transaction and failure behavior explicit.

First define the rules: which records must commit together, when access begins, what a payment timeout means, and how retries avoid duplicate effects. Then refactor the responsibilities that enforce those rules.

A small policy object illustrates how one decision can become directly testable:

# app/policies/subscription_access.rb
class SubscriptionAccess
def allowed?(payment_settled:, expires_at:, now:)
payment_settled && now < expires_at
end
end

Passing time explicitly makes the expiration boundary deterministic. This example covers payment state and expiration only. Tenant authorization, persistence, payment provider callbacks, and concurrent updates require their own checks.

For every debt item closed, explain which maintenance burden or failure mode disappeared. A lower warning count becomes more useful when the team can connect it to a clearer business boundary.

7. Build toward 95% unit test coverage

A test can execute a line without checking a useful outcome. Start with meaningful assertions, then use coverage to find what remains unexamined.

The policy above needs tests for both ordinary behavior and the precise expiration boundary. The following Minitest example uses explicit times and requires no Rails boot:

Code
# test/unit/subscription_access_test.rb
require "minitest/autorun"
require_relative "../../app/policies/subscription_access"

class SubscriptionAccessTest < Minitest::Test
  def setup
    @policy = SubscriptionAccess.new
    @expires_at = Time.utc(2026, 11, 14, 12, 0, 0)
  end

  def test_allows_settled_payment_before_expiration
    assert allowed?(settled: true, at: @expires_at - 1)
  end

  def test_rejects_unsettled_payment
    refute allowed?(settled: false, at: @expires_at - 1)
  end

  def test_rejects_access_at_the_expiration_boundary
    refute allowed?(settled: true, at: @expires_at)
  end

  def test_rejects_access_after_expiration
    refute allowed?(settled: true, at: @expires_at + 1)
  end

  private

  def allowed?(settled:, at:)
    @policy.allowed?(
      payment_settled: settled,
      expires_at: @expires_at,
      now: at
    )
  end
end

The exact-boundary test would detect an accidental change from < to <=. A happy-path test alone would miss that defect even if every line executed.

Untangling software modules

Conceptual illustration: inspect distinct behavior boundaries rather than relying on one coverage percentage.

Make the coverage scope explicit

Track application-owned Ruby code, including files the suite never loads. Document justified exclusions and keep difficult legacy code in scope.

The following configuration uses the compatibility API documented by SimpleCov. Match the method names to the version in the project's lockfile; newer releases document replacements for some of these names.

For a single-process unit job, start coverage before requiring application code:

Code
# test/unit_helper.rb — load this before application code
require "simplecov"

SimpleCov.start "rails" do
  track_files "{app,lib}/**/*.rb"
  enable_coverage :branch
  command_name "unit"
  coverage_dir "coverage/unit"
  use_merging false
  minimum_coverage line: 95
end

require "minitest/autorun"

This assumes SimpleCov and Minitest are available in the test bundle. Unit files must load the helper first; the policy test can replace its first require with require_relative "../unit_helper".

Use a fresh CI workspace and run the dedicated unit directory:

bundle exec ruby -Itest -r ./test/unit_helper -e \
'Dir["test/unit/**/*_test.rb"].sort.each { |file| require File.expand_path(file) }'

This sample deliberately disables result merging for one process. A parallel suite needs explicit shard collection and a final merged unit-only report. Keep integration and system results separate from a claim about unit coverage.

The 95% threshold is an eventual acceptance target. Raise a credible baseline in stages rather than setting an unattainable gate and teaching the team to bypass it. Track branch coverage separately and investigate the business meaning of untested decisions.

Keep integration tests for database behavior, transactions, authorization wiring, queues, and provider boundaries. Retain a small number of system tests for essential user journeys. These layers answer questions that isolated unit tests cannot.

8. Deploy within a rehearsed recovery boundary

If application changes require a database migration, separate additive compatibility work from destructive cleanup. Introduce the new representation, backfill in resumable batches, verify consistency, and switch readers under an explicit write strategy. Remove the old representation after the recovery window closes.

Do not introduce schema changes simply because the framework is changing.

Rehearse with representative, appropriately sanitized data. Verify new workers against existing queued jobs and rollback workers against payloads produced during the release. Use controlled draining or queue versioning when mixed-version processing cannot be made compatible.

Promote the same immutable artifact through validation and deployment. Where shared-state compatibility permits a canary, expose a small portion of traffic and compare it with the baseline. Avoid duplicating irreversible actions such as payments while comparing versions.

Monitor business outcomes alongside latency and infrastructure health: access-grant failures, duplicate invoices, authorization denials, deserialization errors, queue age, connection pressure, and memory growth.

Set pause and recovery criteria before release. Redeploying old code cannot reverse an incompatible data transformation, and restoring a database cannot undo a payment or email already sent.

9. Make the next upgrade easier

Close the migration with evidence: the deployed versions, vulnerability dispositions, reviewed debt closures, a reproducible coverage report, and a recovery rehearsal.

Measure performance improvements independently using comparable workloads, data, concurrency, and resource limits. A framework update alone does not establish lower latency or lower infrastructure cost.

Keep dependency maintenance, advisory ownership, test execution, and deployment checks in the normal delivery workflow. Temporary compatibility settings need removal dates, and new deprecations need owners.

Then perform an ordinary feature change and a small dependency update through the improved workflow. Both should be easier to understand, verify, and release. That is the practical outcome a legacy modernization effort should leave behind.

References

SHARE THIS NOTE

← Back to notes