How to Upgrade a Rails App Without a Big-Bang Rewrite
The client’s first email read: “We’re on Rails 6.1. Our senior dev says we need to upgrade but it’ll take six months and we have to stop all feature work. Is that true?”
I hear some version of this every few months. The answer is almost always no — but the approach matters enormously.
After nineteen years of Rails, I’ve upgraded dozens of apps. Most of them were behind. Some were embarrassingly behind: Rails 4.2 in 2022, Rails 5.0 with a Gemfile that had fifteen gems marked git: 'some-abandoned-repo'. None of them required a big-bang rewrite or a six-month feature freeze. Every single one got upgraded incrementally, in production, while the business kept shipping.
Here’s how.
Why Big-Bang Fails
The instinct is to spin up a branch, bump Rails to the latest version, fix everything that breaks, and merge when it works. That branch lives for six months. The main branch keeps moving. Your upgrade branch drifts further from reality with every passing week. Eventually you’re not upgrading — you’re rewriting. Two things happened simultaneously: you tried to upgrade Rails and you tried to catch up with six months of main branch changes. Neither ends cleanly.
The alternative is incremental upgrades: one minor version at a time, on a short-lived branch, merged back to main within a week. You may ship ten times instead of once. It’s still faster.
Step Zero: Before You Touch the Gemfile
Two things to do before you change a single version number.
First: fix your test coverage. You don’t need 100%. You need coverage of the business-critical paths — the flows that make your customer money or that, if broken, would create customer support chaos. If you have no tests, write them before you upgrade. Even 30% meaningful coverage is better than flying blind.
Second: turn on deprecation warnings and fix them all. Rails makes this straightforward:
# config/environments/development.rb
config.active_support.deprecation = :raise
Run your test suite. Run your app locally for a few days. Every deprecation warning is a thing that will break in the next Rails version. Fix them now, while you’re still on the version that understands what you’re doing wrong.
For production, log deprecations rather than raising:
# config/environments/production.rb
config.active_support.deprecation = :log
Then check your logs. You’ll be surprised what’s lurking.
The Upgrade Path
Rails follows a clear major.minor.patch versioning scheme. The supported upgrade path is sequential minor versions:
6.0 → 6.1 → 7.0 → 7.1 → 8.0
You cannot skip minor versions. Do not try to jump from 6.1 to 8.0 in one shot — the cumulative deprecation changes and framework shifts will produce a debugging nightmare. Each minor version bump comes with a targeted set of changes, and the upgrade guides are genuinely good. Use them.
For each version step:
- Update
gem 'rails', '~> X.Y'in your Gemfile - Run
bundle update rails(only Rails, not everything) - Run
bin/rails app:update— this updates generated files likeconfig/application.rb,config/boot.rb, and initializers - Review each change the generator makes with
git diff - Run your test suite
- Fix what breaks
- Enable the new framework defaults for the next version via the generated initializer
That last step is critical. Rails ships new defaults in a separate initializer file so you can adopt them incrementally:
# config/initializers/new_framework_defaults_7_1.rb
# Generated by bin/rails app:update
Rails.application.config.action_dispatch.default_headers = {
"X-Frame-Options" => "SAMEORIGIN",
"X-XSS-Protection" => "0",
"X-Content-Type-Options" => "nosniff",
"X-Permitted-Cross-Domain-Policies" => "none",
"Referrer-Policy" => "strict-origin-when-cross-origin"
}
Don’t blindly apply all new defaults at once. Read each one. Understand what changes. Apply, test, ship. Repeat.
Gem Dependency Hell
This is the real work. Rails upgrades are often blocked not by Rails itself, but by gems that pin to old Rails versions.
Start with a health check:
bundle outdated --strict
For any gem that hasn’t been updated in years:
- Check if it’s actively maintained (GitHub commits, RubyGems download curve)
- Check if a maintained fork exists
- Check if the functionality is now in Rails core — the ecosystem has been steadily absorbed over the years
- If abandoned: replace it, inline it, or delete it
Common patterns I run into:
acts_as_paranoidorparanoia— replace with the Discard gem or a simpledeleted_atscope- Ancient authentication gems — Rails 8 ships a full generator with
rails generate authentication paperclip— replaced by Active Storage, which has been in Rails since 5.2- Legacy admin gems pinned to old Rails — evaluate whether Administrate or a custom admin is the path forward
For gems that aren’t quite compatible but aren’t actively blocking either:
# Gemfile — temporary bridge only
gem "some-gem", github: "author/some-gem", branch: "rails-8-compat"
Only use git dependencies as a temporary measure. Pin to a specific commit hash if you don’t trust the branch:
gem "some-gem", github: "author/some-gem", ref: "abc1234"
Mark these with a # FIXME: remove once gem releases X.Y comment so they don’t live forever.
The Gotchas for Each Version
Rails 7.0: The biggest change is Zeitwerk becoming mandatory for autoloading. If your app has non-standard load paths or monkeypatching tricks in initializers, this is where they surface. Run bin/rails zeitwerk:check immediately after upgrading. It lists exactly what it can’t autoload and why.
Rails 7.1: config.active_record.query_log_tags_enabled is now true by default. This adds annotation comments to your SQL queries — excellent for debugging, occasionally surprising if you have tests that assert on raw SQL strings. Also, before_action with if: conditions changed subtly around evaluation timing. Review your controller callbacks if anything behaves unexpectedly.
Rails 8.0: Propshaft replaces Sprockets as the default asset pipeline. If you’re still on Sprockets, you have two options: opt out with gem 'sprockets-rails' and keep it working, or migrate. The Propshaft migration is worth doing eventually but does not need to block your Rails 8 upgrade. Also notable: Solid Queue, Solid Cache, and Solid Cable are now the defaults. You don’t have to adopt them immediately — your existing Redis/Sidekiq setup keeps working fine — but understand they exist and evaluate them on your own timeline.
Testing the Upgrade in Production
I strongly prefer deploying upgrades to a canary instance before rolling out to the full fleet. With Kamal 2, this is straightforward:
# config/deploy.yml
servers:
web:
hosts:
- 10.0.0.1 # canary
- 10.0.0.2
- 10.0.0.3
Deploy to the canary, watch your error tracker (Sentry, Honeybadger, wherever) for five minutes. If the error rate doesn’t spike, roll to the full fleet.
If you don’t have canary deployments yet, at minimum deploy during low-traffic hours and have your rollback procedure written down before you start. A Rails version rollback is typically git revert + bundle install + redeploy — fast if you’re prepared, chaotic if you’re not.
What “Done” Actually Looks Like
A completed Rails upgrade has four attributes:
bundle exec rails -vreturns the target version- All deprecation warnings are gone — verify this two weeks post-deploy by checking production logs
bin/rails app:updatehas been run and new framework defaults have been reviewed and adopted- The upgrade branch is merged and deleted — there is no living “upgrade branch” in your repo
That last one matters more than it sounds. If the branch is still open, the upgrade is in-progress purgatory, not done. Close it or kill it.
The Hidden Dividend
There’s a benefit to staying current that never shows up in a ticket or a sprint: each incremental upgrade is smaller and easier than the last. The team that ships Rails 8.1 six weeks after it releases does about ten minutes of work. The team that hasn’t upgraded in four years does six months.
A client once asked me what it would cost to upgrade their Rails 4.2 app. My answer: roughly the same as if they’d upgraded once per year for four years. Except now, because of four years of compounding delay, they had to do all that work in a compressed timeline with compressing business pressure and a product roadmap that couldn’t pause. The debt compounds. Interest is charged in developer morale and shipping velocity.
Stay current. Your future self will thank you.
Frequently Asked Questions
How long does a single minor version upgrade take?
For a well-tested app with reasonably current gems: half a day to two days. For a legacy app with poor test coverage and many outdated gems: one to two weeks. The test suite and gem dependencies are almost always the bottleneck, not Rails itself.
Should I upgrade Ruby and Rails at the same time?
No. One at a time. Upgrade Ruby first — it’s usually simpler and the error messages are clearer — deploy it, let it run in production for a week, then start the Rails upgrade. Combining both changes doubles your debugging surface area when something breaks.
What if a critical gem is abandoned and there’s no replacement?
Three options: inline the relevant code into your app (gems are often smaller than they look once you strip the abstraction), fork and maintain it yourself (viable if the gem is stable and changes slowly), or pay someone who understands the domain to build a replacement. I’ve done all three. Inlining is usually faster than it sounds and eliminates a dependency permanently.
How do I handle database schema compatibility during a multi-step upgrade?
Your schema is independent of Rails versioning. Migrations generated in Rails 6 run fine in Rails 8. The only concern is if a migration uses syntax that Rails 8 has deprecated — but the migration files themselves don’t need modification. Focus your database attention on ActiveRecord API changes (query interface updates, encryption, strict loading), not on the schema files.
Do I need to enable all the new framework defaults immediately?
No. The new-defaults initializer files are designed for gradual adoption. Enable one new default, test it, and ship — then repeat for the next. This is the intended workflow. Don’t feel pressure to flip every switch at once. The file generated by app:update serves as your checklist; work through it at your own pace.
Upgrading a legacy Rails app and not sure where the bodies are buried? TTB Software has been doing Rails since version 1.2. We’ll find the blockers, clear the path, and get you current — without stopping your team.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation