Upgrading a Rails 7 app to Rails 8
bundle update rails finishes in ninety seconds and the suite comes back green, which is the part
of a Rails 7 to 8 upgrade nobody has trouble with. The week goes somewhere else: into a default you
switched on in the same commit as the gem bump, into an asset pipeline that did not move because
nothing asked it to, and into a table that exists in every environment except the one that matters.
Rails 8.0 shipped on 2024-11-07 and Rails 8.1 on 2025-10-22. Both want Ruby 3.2.0 or newer, so an application still on Ruby 3.1 has a language upgrade in front of the framework one.
Go one minor version at a time, and leave load_defaults alone while you do it
The Rails upgrade guide gives the process as a loop: move to the latest patch of the version you are on, fix the tests and the deprecations it reports, move to the latest patch of the next minor version, repeat. From 7.0 that is 7.0.x, then 7.1, then 7.2, then 8.0, then 8.1. Five stops for a bump that looks like one line in a Gemfile.
The position worth arguing for is the second half of that: run the whole ladder with
config.load_defaults frozen at the value it has today. A Rails upgrade is two independent changes
wearing one name. One is the gem: new code, removed methods, changed signatures. The other is the
defaults: behaviour your application opted into years ago and now opts out of. Keep them apart and
every failure has one possible cause. Ship them together and a broken conditional GET could be the
new framework version or could be strict_freshness, and you find out by reverting both.
The step people skip is exactly that separation. bin/rails app:update offers a diff, the diff
mentions load_defaults, flipping it feels like part of the same job, and the commit that lands is
"upgrade to Rails 8" containing forty behaviour changes nobody enumerated. The cost is not the
outage. The cost is the week afterwards spent bisecting a commit that cannot be bisected, because
every change in it arrived at once.
The other reason to do the upgrade from 7.2 to 8.0 as its own step is that 7.2 is where you find out whether your test suite is honest. If it was not catching regressions on 7.1 it will not catch them on 8.0 either, and the framework bump is a bad time to learn that.
What bin/rails app:update actually edits
bin/rails app:update is a generator run in reverse. It walks the files rails new would have
produced at the new version, compares each one to what you have, and asks per file whether to
overwrite, skip, or show the diff. So it reaches config/boot.rb, config/environments/*.rb, the
scripts under bin/, the Dockerfile, and it writes one new file:
config/initializers/new_framework_defaults_8_0.rb, every option in it commented out.
Four things it does not do, and all four surprise somebody:
- It does not edit your
Gemfile. Bumping the Rails version there and runningbundle update railsis a separate step you do first, and any gem that ships as part of a new application (propshaft,solid_queue,kamal,thruster) stays absent unless you add it yourself. - It does not flip
config.load_defaults. The guide is explicit that you change that line by hand, after the new defaults initializer is empty. - It does not touch anything under
app/,lib/, orconfig/routes.rb. - It does not know which of your
config/environments/production.rbedits were deliberate. AnsweringYto that file overwrites yourforce_ssl, your logger and your cache store in one keystroke.
Run it with a clean working tree. The useful answer to most prompts is d, then decide, and the
file that repays reading the diff line by line is production.rb.
The load_defaults ladder, and what 8.0 and 8.1 switch on
config.load_defaults 8.0 in config/application.rb is one integer standing in for a set of
framework flags, and each version's set is additive: 8.1 gives you everything 8.0 gave you plus its
own. That is why the number is worth moving on its own commit, and why new_framework_defaults_8_0.rb
exists at all. The initializer is a staging area. Every flag the new number would set is listed there
commented out, you uncomment them one or two at a time across several deploys, and when the file is
empty you delete it and change the integer. Nothing observable happens at that last step, which is
the point.
Rails 8.0 changes three things:
Regexp.timeout = 1
config.action_dispatch.strict_freshness = true
config.active_support.to_time_preserves_timezone = :zone
Rails 8.1 adds seven more, and two of them raise rather than warn:
config.action_controller.action_on_path_relative_redirect = :raise
config.action_controller.escape_json_responses = false
config.action_view.remove_hidden_field_autocomplete = true
config.action_view.render_tracker = :ruby
config.active_record.raise_on_missing_required_finder_order_columns = true
config.active_support.escape_js_separators_in_json = false
config.yjit = !Rails.env.local?
raise_on_missing_required_finder_order_columns is the one to uncomment first and alone. It turns
.first and .last on a relation with no order into an exception, which is a correctness
improvement and also a change that finds code paths your fixtures never reach. Regexp.timeout = 1
belongs on its own deploy for the same reason from the other direction: a validator that runs in
microseconds against a twelve-character fixture can take longer than a second against a real user's
paste, and Regexp::TimeoutError is not an exception anything in your stack is catching.
Sprockets does not become Propshaft on its own
Propshaft became the default asset pipeline in Rails 8.0, replacing Sprockets, and the word "default"
is doing precise work there. rails new on 8.0 puts propshaft in the Gemfile. An application
generated on Rails 6 has sprockets-rails in its Gemfile, app/assets/config/manifest.js on disk,
and app:update does not edit either, so it comes out of the upgrade running Sprockets on Rails 8.
That is supported, it is fine, and it is a decision you should make rather than inherit.
Propshaft documents the move from Sprockets to Propshaft in its own UPGRADING.md, which is short.
The incompatibilities are not:
- Delete
app/assets/config/manifest.jsentirely. Propshaft has no manifest and no//= linkdirectives. - Drop
sprockets,sprockets-railsandsass-rails, and removerequire "sprockets/railtie"if you are not onrails/all. - Propshaft does not compile anything. No ERB in
.css.erbor.js.erb, no Sass, no CoffeeScript. Transpiling moves tocssbundling-railsortailwindcss-railsand a build step. - Asset helpers inside stylesheets go away.
image-url("logo.png")becomes plainurl("logo.png"), and Propshaft resolves and digests it when it compiles the file. - Those
url()references are resolved relative to the stylesheet. A path that worked in Sprockets because everything lived in one flat load path needs a leading/to mean the same thing.
The cost of moving is the build step you now own, in development as well as production. The cost of not moving is that you are the application keeping a pipeline alive that new Rails apps no longer generate, and the gap only widens.
The Solid trio swaps adapters and leaves you the tables
Solid Queue, Solid Cache and Solid Cable arrived in Rails 8.0 as database-backed replacements for
the Redis-backed adapters, and switching to them is three lines of configuration in two files. What
those three lines do not do is create anything. Solid Queue's tables land in your primary
db/schema.rb if you install it with its generator. Solid Cache and Solid Cable ship theirs in
standalone files, db/cache_schema.rb and db/cable_schema.rb, written to be loaded into their own
databases.
The four-database layout in config/database.yml that Rails 8 generates for production is the happy
path, and db:prepare sets all four up. The unhappy path is a single managed Postgres, where every
entry in database.yml resolves to the same DATABASE_URL. db:prepare initializes the primary,
then reaches the cache and cable entries, sees a database that is already set up, and skips them.
solid_cache_entries and solid_cable_messages are never created. The deploy succeeds. The first
Turbo Stream broadcast answers 500 with relation "solid_cable_messages" does not exist.
Here is the part that makes it a week rather than an afternoon. Rails generates
config/environments/test.rb with config.cache_store = :null_store, and Active Job's test adapter
is what runs in the suite. Nothing in your tests writes a cache entry to Postgres, enqueues through
Solid Queue, or publishes over Solid Cable. The suite is not weakly covering those tables, it is
structurally incapable of touching them, so green means the adapters are configured and says nothing
at all about whether their tables exist. The check that would have caught it is one console line
against the production database asking for the table, and it is not in anybody's upgrade checklist.
Running Rails 8 without Redis goes through what the three adapters buy once they are up, including the place where Rails 8 rate limiting and Solid Cache do not compose.
The authentication generator is a new-app generator
bin/rails generate authentication is listed among the Rails 8.0 headline features, and reading it
as an upgrade path is a mistake worth catching early. The generator writes app/models/user.rb,
app/models/session.rb, app/models/current.rb, app/controllers/sessions_controller.rb,
app/controllers/passwords_controller.rb, app/controllers/concerns/authentication.rb, a
PasswordsMailer with both view formats, and an Action Cable Connection. It adds two route
entries, injects an include Authentication into your ApplicationController, adds bcrypt, and
generates CreateUsers and CreateSessions migrations unless a User already exists.
Every one of those names is a file an application with authentication already has. Nothing in
app:update runs this generator, and running it yourself on an upgraded app with Devise in it
produces a collision on User, a second set of session routes, and a concern fighting your existing
current_user. Upgrading to Rails 8 does not migrate your authentication, and the generator is not
offering to.
Kamal and Thruster are a separate decision
Kamal 2 and Thruster are what rails new wires up on Rails 8.0: a config/deploy.yml, a production
Dockerfile, bin/thrust, and the two gems. Upgrading gets you none of it, and that is the right
default. Changing your deployment in the same window as your framework version means a failed boot
has two candidate causes and you own neither of them well yet.
Kamal 2.8 and later can run a local registry, which removes the container registry from the list of things to set up before a first deploy. Worth knowing when you do make the move, and not a reason to make it during an upgrade.
The deprecation that is only a warning until it is not
Rails 8.1's removal list is, almost line for line, Rails 8.0's deprecation list. Benchmark.ms was
deprecated in 8.0 and is gone in 8.1. The SQLite3 adapter's :retries option was deprecated in
favour of timeout in 8.0 and is gone in 8.1. bin/rake stats, STATS_DIRECTORIES and
rails/console/methods.rb were all deprecated in 8.0 and are all removed in 8.1. Routes drawn with
multiple paths, get ["/a", "/b"] => "pages#show", were deprecated in 8.0 and the support is
removed in 8.1, which means an application that booted with a warning on 8.0 does not boot at all on
8.1.
config.active_support.to_time_preserves_timezone is the sharpest version of the pattern, because
it moves twice. load_defaults 8.0 sets it to :zone. Rails 8.1 deprecates the option itself, which
is the framework saying the behaviour is no longer something you opt into. An application that
pinned it back to :offset in new_framework_defaults_8_0.rb and then forgot has a dated deadline
it does not know about, and the symptom when it arrives is times drifting by an hour across a DST
boundary rather than anything that looks like an error.
Now the reason these accumulate quietly. The config/environments/production.rb that Rails
generates contains this:
# Don't log any deprecations.
config.active_support.report_deprecations = false
Deprecation warnings are reported in development and in test, and silenced in production. Which is
defensible for log volume and is also backwards with respect to where the warnings come from. Your
fixtures are twenty rows with tidy values; production is the environment with the twelve-year-old
column, the legacy route, the encoding nobody remembers choosing. The code paths most likely to be
sitting on a deprecation are the ones only production data reaches, and production is where nobody
is listening. Flip it to :log for the duration of an upgrade, read what comes out for a week, flip
it back.
schema.rb rewrites itself the first time you migrate on 8.1
Active Record sorts the columns inside schema.rb alphabetically from Rails 8.1, so that the file
stops depending on the order columns happened to be added on whichever machine dumped it last. The
practical consequence is one enormous diff the first time anybody runs a migration after the
upgrade, arriving in whatever pull request that migration was part of. Run bin/rails
db:schema:dump on its own and land the reordering as its own commit before that happens.
What this page does not cover
Everything above starts from a Rails 7.x application, so the 6.1 to 7.0 step is missing, and it is
the one with Zeitwerk in it. Also missing: Webpacker, which is unmaintained and whose exit is its own
project; multi-database applications with real replicas, where connects_to changes are a chapter of
their own; and engines, whose upgrade is mostly other people's release notes. Ruby 3.1 to 3.2 is
named here as a prerequisite and not explained.
The boilerplate this site sells runs Rails 8.1.3.1, with Propshaft, the three Solid adapters, Kamal and Thruster, which is the stack a new Rails 8 application generates rather than the one an upgraded Rails 7 application lands on. Reading a Gemfile.lock that already sits where you are trying to get is occasionally the fastest way to see what the finished state looks like.
Comments
No comments yet. Be the first.