Rails 7 shipped in December 2021, and there are still a lot of production apps sitting on 6.1. We know because taking over those apps is a large part of what we do. The upgrade has a reputation for being painful, and it’s partly deserved: Rails 7 removed the classic autoloader, retired Webpacker, made Sprockets optional, and changed how cookies are signed. None of it is hard individually. What hurts teams is doing it in the wrong order, or all at once.
Here’s the order we use. We’ve run this upgrade on complex production apps without downtime, including apps that hadn’t been touched in years.
Prerequisites
Ruby version. Rails 7.0 and 7.1 require Ruby 2.7.0 or newer, and 3.0+ is where you want to be. If you’re on 2.6 or older, upgrade Ruby first, deploy it, and let it soak before you touch the Rails version. Two runtime upgrades in one deploy means two suspects for every new error.
Latest 6.1 patch. Move to the newest 6.1.x release first and clear every deprecation warning your logs and test suite produce. Rails deprecation warnings on 6.1 are the exact list of things that will break on 7.0. Free migration plan; read it.
A trustworthy test suite. On the apps we maintain we target 80-90% automated test coverage, and upgrades are where that investment pays out. If your suite is thin, write request specs for the flows that make you money before starting. It feels slow. It’s much faster than bisecting a production incident.
Step 1: Get Zeitwerk working on Rails 6
Rails 7 only supports the Zeitwerk autoloader; the config.autoloader= setting was deleted. The
good news is Zeitwerk has been the default since 6.0 and you can adopt it fully while still on
6.1, which keeps the autoloader migration out of your Rails 7 diff.
bin/rails zeitwerk:check
If that passes, you’re done. If not, the failures are almost always files whose names don’t match
the constants they define. Fix them on 6.1, deploy, move on. Classic-mode holdouts (usually
require_dependency calls and initializers that reference autoloaded constants) get cleaned up
here too.
Step 2: Deal with the front end
Rails 7 retired Webpacker. New Rails 7 apps use importmap-rails by default, with jsbundling-rails and cssbundling-rails as the escape hatches for apps that genuinely need a bundler. For an existing app you have three honest options:
- importmap-rails if your JavaScript is modest and doesn’t need transpilation. This is the most maintenance-free destination. We migrated this site that way and deleted the entire Node toolchain in the process.
- jsbundling-rails (esbuild is the pragmatic pick) if you have React or a real build step.
Your
app/javascriptmostly survives intact; the build config gets dramatically smaller. - shakapacker if you need Webpacker semantics and can’t schedule the migration yet. It’s a maintained fork and a legitimate bridge, but treat it as a bridge.
Also note: the rails gem no longer depends on sprockets-rails. If you use the asset pipeline
(you almost certainly do), add it to your Gemfile explicitly before the bump:
gem "sprockets-rails"
Do the front-end migration as its own project on Rails 6.1 if you can. It’s the biggest chunk of work in the whole upgrade and there’s no reason to couple it to the framework bump.
Step 3: The gem sweep
Go through the Gemfile and check every gem for a Rails 7-compatible release. bundle outdated
plus changelogs gets you most of the way. Specific known tripwires:
- spring must be 3.0.0+ or it fails with
undefined methoderrors. Rails 7 also stopped including it by default; consider just removing it. - Anything that patches Active Record or Action View internals (soft-delete gems, admin frameworks, decorators) needs a version check, not an assumption.
- Gems abandoned since 2021 need a replacement decision now, not during the bump.
Step 4: Dual boot and bump
We run the app against both versions from one branch so the upgrade doesn’t freeze feature work:
# Gemfile
rails_version = ENV.fetch("RAILS_VERSION", "6.1")
gem "rails", "~> #{rails_version}.0"
RAILS_VERSION=7.0 BUNDLE_GEMFILE=Gemfile.next bundle install
RAILS_VERSION=7.0 BUNDLE_GEMFILE=Gemfile.next bin/rails test
Fix failures under the 7.0 boot until green, then make it the real Gemfile and run:
bin/rails app:update
Accept the config changes it proposes (diff them, don’t rubber-stamp them), and you’ll get
config/initializers/new_framework_defaults_7_0.rb with the new defaults commented out. Leave
config.load_defaults 6.1 in place for now. Enable the new defaults one at a time, test, deploy.
When they’re all on, switch to config.load_defaults 7.0 and delete the initializer.
Step 5: The changes that actually bite
These are the 7.0 behavior changes that have caused real incidents, and what to do about each:
Cookie signing digest changed from SHA1 to SHA256. If you flip this default without a rotator, every user gets logged out and every signed cookie becomes invalid. Rails provides a cookie rotator pattern for exactly this; configure it so old SHA1 cookies are read and rewritten as SHA256:
# config/initializers/cookie_rotator.rb
Rails.application.config.after_initialize do
Rails.application.config.action_dispatch.cookies_rotations.tap do |cookies|
authenticated_encrypted_cookie_salt = Rails.application.config.action_dispatch.authenticated_encrypted_cookie_salt
signed_cookie_salt = Rails.application.config.action_dispatch.signed_cookie_salt
secret_key_base = Rails.application.secret_key_base
key_generator = ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000, hash_digest_class: OpenSSL::Digest::SHA1)
key_len = ActiveSupport::MessageEncryptor.key_len
old_encrypted_secret = key_generator.generate_key(authenticated_encrypted_cookie_salt, key_len)
old_signed_secret = key_generator.generate_key(signed_cookie_salt)
cookies.rotate :encrypted, old_encrypted_secret
cookies.rotate :signed, old_signed_secret
end
end
Keep the rotator around for a cookie lifetime, then delete it.
New cache serialization format. Rails 7.0 writes cache entries in a format Rails 6.1 can’t
read. During a rolling deploy, or any period where 6.1 and 7.0 servers share a cache, that’s an
error factory. Set config.active_support.cache_format_version = 6.1 until every node is on 7.0,
then bump it in a later deploy.
button_to with a persisted object now generates a PATCH form to the update route rather
than POST. If you used button_to for custom member actions, re-check each one.
Request#content_type now returns the whole header, charset included. Code doing
request.content_type == "application/json" breaks; use request.media_type instead.
How we sequence the deploys
The pattern behind all of the above: never change the framework version and its behavior defaults in the same deploy. Our sequence on a production app looks like this:
- Deploy Rails 7.0 with
config.load_defaults 6.1, the cookie rotator installed, andcache_format_version = 6.1. Behavior is intentionally boring; only the framework changed. - Watch error tracking for a few days. Anything that surfaces now is a genuine 7.0 incompatibility, not a defaults change, which makes diagnosis fast.
- Flip the new framework defaults one or two per deploy, riskiest last (the digest and serialization ones). Each flip is independently revertable.
- Finish with
config.load_defaults 7.0, delete the defaults initializer, and later remove the cookie rotator once old cookies have aged out.
It’s more deploys than the impatient version. Every one of them is small enough to roll back without a meeting.
Step 6: On to 7.1
Don’t stop at 7.0. Rails 7.1 runs on the same Ruby floor (2.7.0+, though again, be on 3.x) and
the hop is small: the notable items are the local secret file moving to tmp/local_secret.txt,
SQLite’s adapter going strict-strings by default, autoload paths no longer being on $LOAD_PATH,
and the new config.autoload_lib replacing the old hand-rolled lib eager-load config. Same
routine: bump, app:update, walk the new defaults file, flip load_defaults.
From there, 7.2 wants Ruby 3.1+, and the road to Rails 8 is open. We covered that leg in Upgrading Rails 7 to 8.
If you’re several versions behind
The stepwise path above is the only reliable one; version-skipping upgrades trade a sequence of small, explainable failures for one giant unexplainable one. Budget roughly a week per major hop for a mid-sized app with decent tests, more if the front end needs the Webpacker migration. And if nobody on the team has time to own it, that’s the actual problem to solve. An app stuck on an end-of-life Ruby and an unpatched Rails is carrying risk that compounds monthly, whether or not anyone is looking at it.