Reading Time: 12 minutes | Series: Rails Renaissance #1
Three major Rails versions. Two production applications. One nonprofit budget. Here’s what actually happened when we upgraded Prayer Nook and Heis Soma across six years of Rails evolution.
If you’ve ever stared at a Rails upgrade guide while your production app happily runs on an outdated version, you know the feeling. There’s security patches you need, shiny new features you want, and a nagging voice that says “this is only going to get harder.” For the past three years, I’ve been managing that exact tension with two production applications serving nearly 1,000 users in our faith-based nonprofit ministry.
This isn’t another rehash of the official upgrade documentation. This is the real story—complete with the mistakes, the victories, the “oh crap” moments at 2 AM, and the surprising ways that early architectural decisions paid off years later. Whether you’re facing your own Rails migration or just curious about what it’s like to upgrade mission-critical applications with limited resources, grab some coffee and let’s dive in.
Ember’s Note: Even penguins occasionally need to molt their feathers—it’s uncomfortable but necessary for growth. And sometimes you discover you had phoenix wings hiding underneath all along. 🐧🔥
The Starting Point: Rails 6.1 (2021-2022)
Where We Were
Picture this: It’s late 2021, and I’m managing two interconnected Rails applications for The Christian Chain ministry:
Prayer Nook – A web and mobile prayer request platform where our community shares and prays for each other’s needs. Think of it as a sacred social network, but instead of likes, you’re offering prayers.
Heis Soma – Our custom Single Sign-On (SSO) system built on OAuth2. The name comes from Greek (Εἷς Σῶμα – “One Body”), reflecting our theological conviction about unity in the church. It handles authentication for Prayer Nook and was designed to eventually serve other ministry applications.
Both apps were running on:
- Ruby 3.0.2
- Rails 6.1.4
- PostgreSQL 13
- Sidekiq for background job processing
- React Native for our mobile apps
- Webpacker (which, if you’ve used it, you’re already sympathizing with me)
We were deployed on a mix of DigitalOcean and Heroku, with Redis handling our caching and background jobs. It was working. Our users were happy. Everything was… fine.
Why We Stayed (Too Long)
Let me be honest about why we didn’t upgrade immediately when Rails 7 dropped in December 2021:
Resource constraints – As a nonprofit, every hour I spent on migrations was an hour not building new features or serving our community. We ran lean, and major upgrades felt like luxury car maintenance when you’re driving a trusty Honda.
Fear of breaking things – When you have 1,000 people relying on your platform for their spiritual community and prayer needs, “move fast and break things” isn’t exactly a viable strategy. The thought of breaking authentication or losing prayer requests in a botched migration kept me up at night.
The “if it ain’t broke” trap – Rails 6.1 was stable. Security patches were still coming. Why risk disruption? It’s easy to rationalize technical debt when everything seems to be working fine.
Solo developer syndrome – Being the primary (and often only) developer meant no one to pair with on risky operations, no one to sanity-check my migration strategy, and no safety net if I messed something up during deployment.
But as 2022 wore on, the reasons to upgrade started piling up faster than security advisories:
- Webpacker was officially deprecated, and the Node dependency hell was getting worse
- Security vulnerabilities required increasingly complex workarounds
- New feature requests needed capabilities only available in Rails 7+
- The gap between our version and current was widening, making future upgrades scarier
- I wanted to try the new hotness: Hotwire, better defaults, and performance improvements
The tipping point? Rails 7.1 was announced for late 2023, and I realized if I didn’t move soon, I’d be dealing with not one but two major version jumps. Technical debt compounds like financial debt—with interest.
The 6.1 → 7.0 Migration: Into the Fire
Preparation Phase
I spent two weeks just researching before touching any code. This included:
Reading everything:
- The official Rails 7.0 upgrade guide
- DHH’s announcement posts
- Every “Rails 6 to 7 migration” blog post I could find
- GitHub issues for gems we depended on
Identifying breaking changes that would affect us:
- The big one: Webpacker → jsbundling/cssbundling
- Spring being removed (not a huge loss, honestly)
- ActiveStorage attachment changes
- Zeitwerk loader becoming mandatory
- Various API deprecations
Creating a safety net:
- Full database backups (and testing the restore process!)
- Comprehensive test suite run to establish baseline
- Setting up a staging environment that mirrored production
- Communication plan for our users (heads up about potential downtime)
- Rollback procedures documented step-by-step
The Migration Journey
I created a rails-7-upgrade branch and took a deep breath. Here’s what actually happened:
Step 1: Bundle Update (The First Reality Check)
# Gemfile before
gem 'rails', '~> 6.1.4'
# Gemfile after
gem 'rails', '~> 7.0.0'
Running bundle update rails produced… errors. Lots of errors. Several gems weren’t compatible yet with Rails 7:
- Our payment processor gem was stuck on Rails 6
- A custom charting library needed major updates
- Some gems we’d added for convenience had been abandoned
Solutions:
- Forked and patched the payment gem ourselves (filed a PR upstream)
- Found a maintained alternative for the charting library
- Removed two abandoned gems and wrote equivalent functionality (it was simpler than expected)
Time investment: 3 days just resolving gem conflicts and testing alternatives.
Ember’s Wisdom: Sometimes you discover that the gems you thought you needed were just extra weight. Phoenix feathers are lighter than penguin down. 🔥
Step 2: Configuration Updates
Rails provides the helpful rails app:update command to merge new configuration defaults. In theory, this is great. In practice:
$ rails app:update
Overwrite /config/boot.rb? (enter "h" for help) [Ynaqdhm]
You get this prompt for basically every configuration file. I chose “d” (diff) for each one and carefully reviewed what was changing. Key decisions:
- Adopted new encryption defaults (security win!)
- Kept our custom CORS configuration (breaking this would kill our mobile apps)
- Updated ActiveJob configuration to support newer features
- Postponed some new defaults that we could revisit later
Gotcha: The new config.load_defaults 7.0 setting enables ALL Rails 7 defaults at once. This bit me because it changed button behavior in forms (changed from data-confirm to Turbo’s confirmation system). Test everything!
Step 3: The Webpacker → jsbundling Migration (The Boss Battle)
This was the most painful part of the entire upgrade. Webpacker had been the default JavaScript bundler for Rails 6, but it was complex, slow, and officially deprecated. Rails 7 offered several alternatives: import maps, jsbundling-rails, or esbuild-rails.
For our needs (React components, mobile app assets, API interactions), we chose jsbundling-rails with esbuild. Here’s why:
- Faster builds than Webpacker (3-5x in our testing)
- Better compatibility with npm packages
- Simpler configuration
- More control over the build process
The migration process:
# Remove Webpacker
$ bundle remove webpacker
$ rm -rf node_modules/
$ rm package-lock.json
# Install jsbundling-rails
$ bundle add jsbundling-rails
$ rails javascript:install:esbuild
Then came the tedious part—moving every JavaScript file to work with the new system:
// Old Webpacker way
// app/javascript/packs/application.js
import Rails from "@rails/ujs"
import * as ActiveStorage from "@rails/activestorage"
// New jsbundling way
// app/javascript/application.js
import "@hotwired/turbo-rails"
import "./controllers"
import * as ActiveStorage from "@rails/activestorage"
What broke:
- Asset paths changed (had to update image references throughout)
- Some Webpack-specific imports needed refactoring
- Stylesheets compilation changed (moved to cssbundling-rails)
- Development environment needed new npm scripts
Time investment: 2 solid weeks. I’m not exaggerating. Every component needed testing, every asset path needed verification, and the mobile app’s asset pipeline required complete reconfiguration.
But once it worked? Build times dropped from 45 seconds to 8 seconds. Worth it.
Step 4: Test Suite Updates
Our RSpec test suite had ~400 tests. About 30 failed initially:
- Request specs broke because of new CSRF token handling
- System tests failed due to Turbo Drive being enabled by default
- Model tests were mostly fine (praise ActiveRecord stability!)
- Integration tests needed updates for new routing behavior
The fix for most test failures:
# Old way
it "creates a prayer request" do
post prayer_requests_path, params: { prayer_request: attributes }
expect(response).to redirect_to(prayer_request_path(PrayerRequest.last))
end
# New way (Turbo enabled)
it "creates a prayer request" do
post prayer_requests_path, params: { prayer_request: attributes }
expect(response).to have_http_status(:see_other) # 303 instead of 302
expect(response).to redirect_to(prayer_request_path(PrayerRequest.last))
end
Time investment: 3 days to get test suite back to green.
Deployment and Rollout
We deployed to staging first (obviously) and ran through our entire QA checklist:
- ✅ User authentication and SSO flows
- ✅ Creating and viewing prayer requests
- ✅ Background jobs processing (email notifications, etc.)
- ✅ Mobile app connectivity
- ✅ Admin dashboard functionality
- ✅ Payment processing
- ✅ Search functionality
Then came production deployment. I scheduled it for a Tuesday morning (low traffic time) and:
- Sent notification to users about potential brief downtime
- Created final backup
- Deployed new code
- Monitored logs intensely for 6 hours
- Watched our error tracking like a hawk
Result: Smooth as butter. Zero user-reported issues. Response times actually improved by ~15%.
Total time from start to production: 6 weeks (working evenings/weekends)
The 7.0 → 7.1 Interlude
Rails 7.1 released in October 2023, and by this point, I was a smarter upgrader. The jump from 7.0 → 7.1 was relatively painless:
- No major JavaScript changes
- Mostly additive features
- Better defaults we could opt into gradually
- Solid compatibility across our gem ecosystem
Key improvements we adopted:
- Asynchronous queries – Game changer for dashboard loading:
# Before: sequential queries block rendering
@prayer_requests = current_user.prayer_requests.recent
@community_prayers = Community.prayers.latest
# After: queries run in parallel
@prayer_requests = current_user.prayer_requests.recent.load_async
@community_prayers = Community.prayers.latest.load_async
- Better error pages – Development errors became much more informative
- Performance improvements – Noticeable speed boost in complex queries
Time investment: 2 weeks from start to production, mostly testing.
The 7.1 → 8.0 Migration: A Game-Changer for Heis Soma
Rails 8.0 dropped in November 2024, and this is where our story gets really interesting. Remember Heis Soma, our custom SSO system? The architectural decisions we made back in 2021 suddenly looked prophetic.
The Multiple Database Connection Revolution
When we built Heis Soma on OAuth2 instead of Devise, we created a clear separation: Heis Soma managed users and authentication, while Prayer Nook handled everything else. Initially, this meant API calls:
# Prayer Nook in Rails 6.1
# Every request made an API call to Heis Soma for user info
def current_user_info
response = HTTP.get("https://heissoma.org/api/users/me",
headers: { "Authorization" => "Bearer #{session[:token]}" })
JSON.parse(response.body)
end
This worked but had latency overhead. Every page load meant an HTTP request to check user permissions, preferences, and roles.
Rails 7 introduced better multiple database support, and it changed everything. We realized we could give Prayer Nook direct read access to Heis Soma’s database:
# config/database.yml (Prayer Nook)
production:
primary:
<<: *default
database: prayer_nook_production
heis_soma:
<<: *default
database: heis_soma_production
replica: true
host: <%= ENV['HEIS_SOMA_DB_HOST'] %>
Then we created read-only models:
# app/models/heis_soma_record.rb
class HeisSomaRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :heis_soma, reading: :heis_soma }
end
# app/models/heis_soma/user.rb
module HeisSoma
class User < HeisSomaRecord
self.table_name = "users"
# Read-only model for direct user access
def readonly?
true
end
has_many :permissions
has_many :prayer_groups
end
end
Now instead of API calls, we could do:
# Direct database read - MUCH faster
def current_user_full_info
HeisSoma::User.includes(:permissions, :prayer_groups)
.find_by(id: session[:user_id])
end
Results:
- API calls to Heis Soma reduced by ~70%
- Average page load improved from 245ms to 180ms
- Fewer points of failure (no HTTP timeouts)
- Better caching strategies possible
This architecture worked in Rails 7, but Rails 8 made it even better with improved query optimization and connection pooling.
Rails 8: Evaluating Solid Queue and Kamal
Rails 8 introduced two major new features that got my attention: Solid Queue (a database-backed job queue) and Kamal 2 (zero-downtime deployment).
The Solid Queue Consideration
We’ve been using Sidekiq for years, and it’s been rock-solid (pun intended). But Solid Queue is intriguing because:
Pros:
- No Redis dependency (one less infrastructure piece)
- Jobs stored in PostgreSQL (we already back this up)
- Built-in job prioritization
- Lower operational complexity
- Free (Sidekiq Pro costs money for some features)
Cons:
- Newer, less battle-tested
- Potential database load during high job volume
- Need to migrate existing jobs
- Team familiarity with Sidekiq
Our decision: We’re running Solid Queue in our development environment to evaluate it, but keeping Sidekiq in production for now. For a mission-critical system handling prayer notifications and email, we’re prioritizing “boring technology” that we know works. We’ll revisit in mid-2025.
# Gemfile - keeping both options
gem 'sidekiq', '~> 7.0' # Production
gem 'solid_queue', '~> 0.1' # Development/Testing only
The Kamal Deployment Evaluation
Kamal 2 promises simpler deployments using Docker containers. For Heis Soma especially, this is appealing:
Current deployment: Complex mix of Heroku buildpacks, environment variables, and manual config
Kamal promise:
$ kamal deploy
# That's it. Everything containerized and deployed.
Why we’re interested:
- Simplified deployment process
- Better environment parity (dev/staging/prod)
- Potential cost savings (vs. Heroku)
- Container-based architecture is more modern
Why we’re cautious:
- Authentication service is critical—can’t afford deployment issues
- Need time to learn Docker/container patterns
- Current deployment works and is well-understood
- Migration effort is significant
Our plan: We’re experimenting with Kamal on a less critical internal tool first. If that goes well over Q1 2025, we’ll consider migrating Heis Soma by Q3.
The OAuth2 Stability Surprise
Here’s one of the best parts of this whole journey: Our custom OAuth2 implementation in Heis Soma remained remarkably stable across all three Rails upgrades.
Because we built it following Rails conventions and OAuth2 specs (rather than fighting the framework), the code barely changed:
# This controller worked in Rails 6.1, 7.0, 7.1, AND 8.0
class Oauth::AuthorizationsController < ApplicationController
def create
if user_approves_authorization?
authorization = create_authorization_code
redirect_to build_callback_url(authorization.code)
else
redirect_to build_error_callback_url
end
end
private
def user_approves_authorization?
params[:approve] == "true" && current_user.present?
end
# ... rest of OAuth2 flow
end
The lesson? When you follow established patterns and specifications, your code tends to be more upgrade-resistant.
Lessons Learned: The Real Takeaways
After three major Rails upgrades over three years, here’s what I wish I’d known at the start:
1. Upgrade More Frequently
Don’t wait. The longer you wait between versions, the harder it gets. Going from 6.1 → 8.0 in one shot would have been a nightmare. Incremental upgrades (6.1 → 7.0 → 7.1 → 8.0) were manageable because:
- Smaller changes to digest each time
- Less time between “it works” states
- Gem ecosystem stays closer to current
- You learn upgrade patterns that apply to future versions
Recommendation: Plan quarterly upgrade reviews. Even if you don’t upgrade, at least evaluate what’s required.
2. Test Coverage Pays For Itself
Our 400-test suite meant I could upgrade with confidence. Every time something broke, the tests told me exactly what and where. Without tests, I would have been manually clicking through every feature after every change—a recipe for missed bugs and user-reported issues in production.
If you don’t have good test coverage, start before you upgrade. Write tests for your critical paths:
- Authentication flows
- Payment processing
- Data creation/modification
- Any custom business logic
3. Read More Than Just The Docs
Official upgrade guides are necessary but insufficient. I learned as much from:
- Community blog posts about gotchas
- GitHub issues in gems we used
- Rails Discord/Slack conversations
- Upgrade PR reviews from open-source projects
- “War stories” from other developers
Pro tip: Search GitHub for “Rails 7 upgrade” and filter by recently updated to see real migration PRs.
4. Budget 2-3x Your Initial Estimate
My first estimate for 6.1 → 7.0: “Maybe 2 weeks”
Actual time: 6 weeks
Why the gap?
- Gem compatibility issues you can’t predict
- “While we’re here” improvements
- Testing takes longer than expected
- Deployment issues that didn’t show in staging
- Documentation and team communication
Better approach: Take your honest estimate, double it, then add 25%. Upgrade projects always take longer than you think.
5. Architecture Decisions Compound Over Time
The choice to build Heis Soma on OAuth2 instead of Devise seemed like extra work in 2021. But that decision made the Rails 7 multiple-database feature immediately valuable. Good architecture choices early pay dividends years later.
Questions to ask during design:
- Are we following framework conventions or fighting them?
- Will this work with standard patterns?
- Are we over-coupling components?
- What assumptions are we baking in?
6. Nonprofit Constraints Force Good Practices
Limited resources meant I had to be disciplined about:
- Comprehensive testing (couldn’t afford manual QA)
- Clear documentation (future-me needed to understand it)
- Simple solutions (no time for clever complexity)
- Risk mitigation (couldn’t handle extended outages)
Ironically, these constraints made for better software than I might have built with unlimited resources.
The Numbers: Was It Worth It?
Let’s be honest about the investment:
Time spent on all upgrades:
- Rails 6.1 → 7.0: 6 weeks (evenings/weekends)
- Rails 7.0 → 7.1: 2 weeks
- Rails 7.1 → 8.0: 3 weeks
- Total: ~11 weeks of work over 3 years
Tangible benefits:
- Page load times: 245ms → 180ms (27% faster)
- Build times: 45s → 8s (82% faster)
- API calls reduced: 70% decrease
- Security vulnerabilities: Zero critical (vs. accumulating in old versions)
- Developer happiness: Significantly higher (better tools, modern features)
- Infrastructure costs: Slightly lower (more efficient resource usage)
Intangible benefits:
- Confidence in our technical foundation
- Ability to attract developer contributors
- Better positioning for future features
- Professional growth (learned new Rails patterns)
- Community credibility (using modern stack)
Was it worth 11 weeks? Absolutely. Technical debt is like compound interest—pay it off early or pay exponentially more later.
Your Migration Checklist: Learn From My Mistakes
If you’re facing a Rails upgrade, here’s my battle-tested checklist:
Before You Start
- [ ] Full backup of production database (and test the restore!)
- [ ] Test coverage at reasonable levels (aim for 70%+ of critical paths)
- [ ] Staging environment that mirrors production
- [ ] Communication plan for users/stakeholders
- [ ] Rollback procedure documented and tested
- [ ] Time budget that’s 2-3x your estimate
- [ ] Review changelog for your specific version jump
- [ ] Check gem compatibility for all dependencies
During Migration
- [ ] Create dedicated branch for upgrade work
- [ ] Update one major thing at a time (Rails version, then gems, then configs)
- [ ] Run test suite after every step
- [ ] Document every gotcha you encounter (helps next upgrade!)
- [ ] Use diff mode for configuration updates
- [ ] Test all critical user journeys manually in staging
- [ ] Monitor performance metrics before and after
- [ ] Check background jobs are processing correctly
After Deployment
- [ ] Watch error tracking like a hawk (first 24 hours)
- [ ] Monitor performance dashboards
- [ ] Check background job queues aren’t backing up
- [ ] Verify scheduled tasks ran successfully
- [ ] Collect user feedback (even if they don’t report issues)
- [ ] Document lessons learned for next time
- [ ] Update team documentation
- [ ] Celebrate! 🎉 (seriously, these are wins worth acknowledging)
What’s Next in This Series
This is just the first post in the Rails Renaissance series. Coming up:
- Post #2: “Building a Custom SSO” – Deep dive into Heis Soma’s OAuth2 architecture
- Post #3: “Rails 7 Multiple Database Connections” – How we cut API calls by 70%
- Post #6: “Background Jobs That Don’t Keep You Up at Night” – Sidekiq vs Solid Queue
- Post #13: “Hotwire vs React: When to Choose Each” – Framework decisions for 2025
Final Thoughts: The Phoenix Still Has Penguin Feet
Here’s the thing about major upgrades: they’re never just about code. Every migration is a test of your architecture, your discipline, your documentation, and your risk tolerance.
For us, these upgrades validated a core principle: build with the framework, not against it. Our custom SSO could have been a nightmare to maintain across three Rails versions, but because we followed OAuth2 specs and Rails conventions, it just… worked. The Ruby community’s commitment to backward compatibility (with clear deprecation paths) made these transitions possible.
Three years ago, I worried that building custom authentication was too ambitious for a nonprofit developer. Today, that “crazy” decision looks like foresight. Sometimes the penguins that waddle carefully across the ice discover they’ve been building phoenix wings all along.
If you’re sitting on Rails 6.x or earlier, staring at the upgrade guides and feeling overwhelmed: I get it. Start small. Upgrade one version. Build confidence. Learn the patterns. Your future self (and your users) will thank you.
And if you’re wondering whether to roll your own auth or stick with Devise? Well, that’s exactly what we’ll explore in the next post. 😉
Ember’s Closing: “The difference between a penguin and a phoenix isn’t the starting point—it’s the willingness to embrace transformation, one uncomfortable molt at a time. You’ve got this.” 🐧→🔥
Connect & Continue
Have questions about Rails migrations? Drop them in the comments below. I read and respond to everything.
Want migration resources? I’ve created:
- Migration Checklist (downloadable PDF)
- Gem Compatibility Tracker (Rails 6-8 compatibility chart)
- Heis Soma Case Study (full OAuth2 architecture)
- Prayer Nook Case Study (multi-tenant patterns)
Following along? This is post #1 of 24 in my 2025 blog series. Subscribe to get notified when new posts drop (every other Monday).
Found this helpful? Share it with a fellow Rails developer who’s putting off that upgrade:
About This Series
This post is part of the Rails Renaissance series, exploring modern Ruby on Rails development through real-world applications. I’m Topher Warrington, bridging 20+ years of ministry with 20+ years of tech to build meaningful digital experiences. My philosophy: Write code that serves people, build systems that last, and never forget that behind every API call is a human being.
Currently seeking Solutions Engineer and Technical Account Executive roles where I can bridge technology and human needs. Let’s connect.
Technical Notes
Versions referenced in this post:
- Ruby: 3.0.2 → 3.3.0
- Rails: 6.1.4 → 7.0.8 → 7.1.3 → 8.0.0
- PostgreSQL: 13 → 15
- Sidekiq: 6.2 → 7.0
- Node: 14.x → 20.x
Gem migrations:
- Webpacker → jsbundling-rails + esbuild
- Spring → (removed, not replaced)
- Custom OAuth2 → DoorKeeper → Custom again (circular journey!)
Performance stack:
- Monitoring: Scout APM
- Error tracking: Rollbar
- Uptime: UptimeRobot
- Analytics: Custom dashboards + Google Analytics
Tags: #Rails8, #RubyOnRails, #WebDevelopment, #TechDebt, #Migrations, #OAuth2, #NonprofitTech, #PrayerNook, #HeisSoma
Last Updated: January 20, 2025
Reading Time: 12 minutes
Code Examples: All tested on Rails 8.0.0

![[Artistly Design]-019bb9a4-9d4f-7236-8239-c25f18f12ac2](https://topher.codes/wp-content/uploads/2025/01/Artistly-Design-019bb9a4-9d4f-7236-8239-c25f18f12ac2.png)



0 Comments