LaunchKit
← All posts
· 12 min read · by The LaunchKit team · 3 views

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 running bundle update rails is 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/, or config/routes.rb.
  • It does not know which of your config/environments/production.rb edits were deliberate. Answering Y to that file overwrites your force_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.js entirely. Propshaft has no manifest and no //= link directives.
  • Drop sprockets, sprockets-rails and sass-rails, and remove require "sprockets/railtie" if you are not on rails/all.
  • Propshaft does not compile anything. No ERB in .css.erb or .js.erb, no Sass, no CoffeeScript. Transpiling moves to cssbundling-rails or tailwindcss-rails and a build step.
  • Asset helpers inside stylesheets go away. image-url("logo.png") becomes plain url("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.

#rails #upgrade

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.