I maintain several Ruby on Rails applications. Most of these are personal projects that I've built to make managing my daily life a bit easier. These projects range from small to much larger in scale.

I'm a big fan of keeping these projects up to date as they prove to be a good testing ground for any kind of updates. Some of these applications have been built off of Rails 4 or 5 and some them were created with Rails 7. As time passes, I will use something like https://railsdiff.org to see the differences between Rails versions in what is created from fresh applications. I typically apply these changes to each of my applications during the upgrade process.

My deployment strategy varies depending on the application, but there are consistencies across all of the applications. All of the applications are using Docker containers on the "production" environment. And, all of the applications are hosted on x86/AMD64 servers. The differences between the applications is that the newer ones are using Kamal to deploy and the older ones are deployed via Portainer which has a webhook to can be pinged to restart the container. I much prefer the Kamal route instead, but haven't changed over all of the applications yet.

When upgrading to Rails 7.2, I encountered issues early in the process. After updating everything on one application, I went to deploy. As a side note, regardless of the hosting method, I always use a bin/deploy script to trigger production deployments. This is helpful as I don't have to worry about which mechanism I am using to deploy. In this example, the deployment mechanism was a simple Docker build using a remote server and then making a cURL request to Portainer.

I did not dive into the specific changes of Rails 7.2 to see if this is a bug or not. However, based on new Rails 7.2 applications, even with --database postgresql as a default flag, the issue doesn't exist. I blame myself for habits that I've created over the years with the ways I did things back then, but never changed how I do them today. So, while the error messages could have been a bit more specific, I feel that this is a "past me" bug that finally caught up to "present me".

Dockerfile:59
--------------------
  57 |
  58 |     # Precompiling assets for production without requiring secret RAILS_MASTER_KEY
  59 | >>> RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
  60 |
  61 |
--------------------
ERROR: failed to solve: process "/bin/sh -c SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile" did not complete successfully: exit code: 1

I got the error above and unfortunately, it was not very helpful in troubleshooting. Luckily, Ruby on Rails is pretty awesome and we did also get a stacktrace.

 => ERROR [build  9/10] RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile                                                                                               0.4s
------
 > [build  9/10] RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile:
0.417 bin/rails aborted!
0.417 NoMethodError: undefined method `to_sym' for nil (NoMethodError)
0.417
0.417       super(key.to_sym)
0.417                ^^^^^^^
0.418 /usr/local/bundle/ruby/3.3.0/gems/activesupport-7.2.0/lib/active_support/ordered_options.rb:42:in `[]'

Ok... yeah... that's not very helpful either. I spent a few hours trying to figure out what the problem was and why this application was having problems in building the Docker container image. I narrowed down the problem to my config/database.yml file which looks something like this:

default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  url: <%= ENV.fetch("DATABASE_URL") { "localhost" } %>

development:
  <<: *default
  database: example_development

test:
  <<: *default
  database: example_test

production:
  <<: *default
  database: example_production

Solution

Overall... it seems like it's very simple and there's nothing to really write home about. But, the issue here is actually the DATABASE_URL environment variable. For some reason, this is causing the problem, so I updated my Dockerfile to include a dummy variable.

RUN DATABASE_URL=postgres://postgres SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

This resolved the issue!

Alternative Solution

This whole issue is centered around the DATABASE_URL. If you were to use Rails Credentials, then this problem doesn't exist. In the config/database.yml you can switch from an environment variable over to using the Rails Credentials.

url: <%= Rails.application.credentials.database_url %>

And this also works!

Not isolated to just DATABASE_URL

Now that I got this application working and deployed, I went to update another application that was using Kamal for deployments and SQLite as the database. So, there was no DATABASE_URL to configure since the database was hosted locally on that container (mounted through a volume).

However, I got a similar problem on a different application that was using ActiveStorage. My configuration looked similar to

amazon:
  service: S3
  access_key_id: ...
  secret_access_key: ...
  region: us-east-1
  bucket: <%= ENV.fetch("S3_BUCKET") %>

And another application's storage.yml file was using Rails Credentials

amazon:
  service: S3
  access_key_id: ...
  secret_access_key: ...
  region: us-east-1
  bucket: <%= Rails.application.credentials.storage.bucket %>

And I got a similar obscure error message but this time it was

 => ERROR [10/10] RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile                                                                                      4.5s
------
 > [10/10] RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile:
4.404 Error retrieving instance profile credentials: Failed to open TCP connection to 169.254.169.254:80 (execution expired)
4.405 bin/rails aborted!
4.406 ArgumentError: missing required option :name (ArgumentError)
4.406 
4.406       when nil then raise ArgumentError, "missing required option :name"
4.406                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
4.406 /app/app/models/user.rb:107:in `<class:User>'
4.406 /app/app/models/user.rb:78:in `<main>'
4.406 /app/config/routes.rb:167:in `block in <main>'
4.406 /app/config/routes.rb:3:in `<main>'

And I found that passing in the environment variable didn't work here. Instead, I had to update the storage.yml file.

amazon:
  service: S3
  access_key_id: ...
  secret_access_key: ...
  region: us-east-1
  bucket: <%= Rails.application.credentials.storage.bucket || "my_bucket" %>

Honestly, the issue here is that the bucket name isn't truly a "secret". However, I did have different buckets depending on the environment I was working in. So, I needed a configuration that accounted for this.

In hindsight, I should have kept my bucket names similar to the convention of the default storage.yml example where there is a plaintext prefix of the bucket name and then the Rails.env is added in based on the environment.

# amazon:
#   service: S3
#   access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
#   secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
#   region: us-east-1
#   bucket: your_own_bucket-<%= Rails.env %>

Conclusion

Overall the Rails upgrade experience from Rails 7.1 to 7.2 was rather painless. Over the years, I've developed a habit of minimizing gem dependencies and keeping configurations as close to the 'Rails way' as possible. But, sometimes with older applications, things slip through the cracks because they "worked" back then and the configuration files like the database.yml and storage.yml normally aren't revisited during the upgrades.

I hope that these findings can help save you some time!