DOCS LLMs

Updating RailsFast

Updating any template is always going to be a bit difficult, because as you start building your app on top of it many files will diverge from the original template, and conflicts will naturally arise. The worst-case scenario is you need to go file-by-file reviewing the template updates and deciding what to incorporate into your app.

HOWEVER, RailsFast is designed so the amount of effort you need to do is minimized, and ideally you can keep pulling RailsFast improvements into your app without much work. An update is a plain git merge of the template into your app, followed by a few checks that this page lists.

NOTE

This guide was battle-tested by upgrading a whole fleet of real production apps, including apps forked back on v0.x, with AI agents driving every merge and logging every snag. Everything below reflects what those upgrades actually hit.

Make your life easier for updating

As we covered in the Customizing Components section, the best practice if you plan to customize RailsFast components is that you don't edit the RailsFast components in-place. Instead, copy the component you want to change into your own folder and edit your copy.

Same idea for gems: the Gemfile ends with a "GEMS SPECIFIC TO THIS PROJECT" section. Put your app's own gems there, not inside the template-owned regions, and your Gemfile stops conflicting on every future update.

Protect your customizations (merge guards)

Some files are yours from day one: your secrets, your deploy configuration, your app's settings. The template marks them in .gitattributes with merge=ours, which tells Git to keep your version when a merge finds both sides changed the file. It does not freeze the file: Git calls the merge driver only when both sides changed it, so a change made only upstream still arrives, and a deletion conflict still needs you. Review the guarded files after every merge. This is the list the template ships (.railsfast/* covers .railsfast/config.json and .railsfast/renamed, which .gitattributes also lists by name):

Dockerfile merge=ours
.kamal/secrets merge=ours
app/views/pwa/manifest.json.erb merge=ours
config/application.rb merge=ours
config/database.yml merge=ours
config/deploy.yml merge=ours
config/railsfast/railsfast.yml merge=ours
config/initializers/pricing_plans.rb merge=ours
config/credentials.yml.enc merge=ours
config/master.key merge=ours
config/db.key merge=ours
config/backups/*.key merge=ours
.railsfast/* merge=ours

You can add your own. If you edited files that ship with RailsFast and that you can't copy easily into your own folder (for example, layouts or auth views), add a guard for them:

# Keep my customized files when merging template updates
app/views/devise/** merge=ours

This way, if you restyle all your authentication screens, a template update that also changed them won't overwrite your version (and you won't get those upstream changes either). A file you never touched still takes upstream changes as usual.

CAUTION

The .gitattributes line alone does nothing. ours is not a built-in Git merge driver. The guard only works when the driver is configured:

git config merge.ours.driver true

bin/setup configures this for you, but a fresh clone on a new machine (or a CI checkout) doesn't have it. Git then silently falls back to a normal merge on your "protected" files, in the direction of losing your customizations. Verify before any merge:

git config --get merge.ours.driver   # must print: true

Three more truths about guards, each learned on a real upgrade:

  1. Guards are silent, and partial. A guarded file that kept your version prints an ordinary Auto-merging line. And a guarded file that only the template changed since your last merge takes the template's version without asking, because Git calls the driver only when both sides changed it. Nothing tells you an upstream improvement was just discarded. That's why diffing guarded files is a standing step after every update (see Port what the guards kept out).
  2. Guards don't cover deletions. Merge drivers run only for content conflicts. If upstream deletes a file you've modified, you get a raw CONFLICT (modify/delete) that no guard resolves. Hard rule for that moment: on any conflict touching credentials.yml.enc, master.key, or any .key/encrypted file, keep YOUR version, always. The template cleaning up its own sample file is never a reason to delete your live credentials.
  3. Guard only what you must. If you guard huge paths like app/views/**, you will also silently skip upstream improvements (including fixes) for those files.

Guards also run on your own merges

The driver does not know where a merge comes from. It also runs when you merge one of your own feature branches into your app's main. When the branch and main both changed a guarded file, Git keeps main's version and drops the branch's change. There is no conflict and no message. This happened on an app of the fleet: a branch added the SES configuration keys to config/railsfast/railsfast.yml, main had changed the same file, and the merge kept main's copy without the keys.

So make this a standing check after every merge into main: compare each guarded file in the tip of the branch (the merge's second parent) with the merge result, and port the branch's changes by hand.

# HEAD is the merge commit, HEAD^2 is the tip of the branch you merged
git diff HEAD^2 HEAD -- ':(attr:merge=ours)'

The :(attr:merge=ours) pathspec selects every path that .gitattributes marks merge=ours, your own guards too. To check one file, name it: git diff HEAD^2 HEAD -- config/railsfast/railsfast.yml. An empty diff means the result has the branch's version. A diff that is not empty also shows the changes that main made itself, so list only what the branch changed, and make sure that each of those changes is in the result:

git diff HEAD^1...HEAD^2 -- ':(attr:merge=ours)'   # the branch's own changes to guarded files

Port each missing change by hand, commit, and run bin/ci. A fast-forward merge makes no merge commit (HEAD^2 does not exist): Git merged nothing, so there is nothing to check.

And one habit for every branch: a branch (or an agent's report) that changes a guarded file says so, so that whoever merges it knows to run the check.

Before you update: figure out where you are

Three checks, one minute, and they prevent the worst classes of mistake:

# 1. Is this repo actually a fork of the template? (directory names lie)
git remote -v                          # is the railsfast remote there?
git fetch railsfast --prune --tags     # fetch FIRST, and bring the release tags
git merge-base HEAD railsfast/main     # a SHA = real shared ancestry; an error = STOP

# 2. Which release are you on?
cat lib/railsfast/version.rb           # TEMPLATE_VERSION travels with every merge
git tag --contains $(git merge-base HEAD railsfast/main) | head -3

# 3. Establish your baseline: run the gate BEFORE merging
bin/ci

Fetch before you size up the job: measuring against a stale railsfast/main ref can overestimate the work several-fold. And the pre-merge bin/ci run is what lets you say "the merge broke this" versus "this was already broken". If main is already red, fix that first, or you'll debug two problems tangled together.

How to update RailsFast

Work on a branch. Never merge the template directly into main on a production app:

git switch -c chore/template-update

Then merge. Every release is cut from the template's main and tagged (v1.0.0 through v1.15.2 so far), so you have two equally good options:

git merge railsfast/main                           # the latest release (the usual path)
# or pin one release at a time:
git tag --list 'v*' --sort=-v:refname | head -5    # what releases exist?
git merge v1.8.0

Merging a tag is the way to step through several releases one at a time, running the checks for each. Merging railsfast/main also brings anything merged since the last tag. bin/railsfast update prints this flow tailored to your repo.

If you get conflicts, resolve them (Git respects your merge guards, assuming the driver check above passed) and commit the merge.

Generated files: regenerate, don't hand-merge

db/schema.rb and Gemfile.lock conflict on almost every non-trivial update, and the correct resolution is never a careful hand-merge:

  • Gemfile.lock: resolve the Gemfile conflict first, then bundle install. The lockfile regenerates itself.
  • db/schema.rb: take your side, then bin/rails db:migrate applies the template's new migrations and re-dumps the schema authoritatively. Free correctness check: if you did hand-union it first, a zero diff after db:migrate proves your whole resolution was right.

Port what the guards kept out

Guarded files keep your copy wholesale, which also means new template keys and improvements never arrive in them on their own. After merging, diff every guarded path against the release you merged, and port what's missing:

# for each merge=ours path in .gitattributes:
diff <(git show railsfast/main:config/railsfast/railsfast.yml) config/railsfast/railsfast.yml
diff <(git show railsfast/main:config/application.rb) config/application.rb
diff <(git show railsfast/main:config/initializers/pricing_plans.rb) config/initializers/pricing_plans.rb
diff <(git show railsfast/main:.kamal/secrets) .kamal/secrets
# ...and so on down the .gitattributes list (use the tag instead of railsfast/main if you pinned one)

This is mechanical, and it's the step that catches a new config key, a new commented recipe or an improved secrets expression that the guard silently discarded. Watch for nested keys: check paths, not just top-level names.

Using AI agents to resolve conflicts

Merge conflicts are inevitable when you've customized template files and the upstream template has improvements to those same files. The good news: AI coding agents like Claude Code excel at resolving these conflicts intelligently. The fleet of real-app upgrades that road-tested this guide was agent-driven end to end.

Give the agent the whole protocol, not just the merge:

Update this app to the latest RailsFast release, on a branch, following
https://railsfast.com/docs/guides-updating/ exactly: verify
merge.ours.driver is configured, run a baseline bin/ci first, merge
railsfast/main, resolve conflicts (keep my customizations in app-owned
files; regenerate schema.rb and Gemfile.lock instead of hand-merging;
on anything touching credentials or .key files keep mine), diff every
merge-guarded file against the release and port missing keys, do every
step listed for each release I crossed, get bin/ci green, run
bin/railsfast doctor, then open a PR.

An agent given that prompt does the right thing. An agent told only to "merge and resolve conflicts, keep my styling" will skip exactly the steps this page says the merge cannot do by itself.

TIP

Enable Git's rerere ("reuse recorded resolution") before merging. It records how you resolve each conflict and replays it if you ever redo the merge: git config rerere.enabled true (bin/setup turns it on too).

After updating

In order. Every step is here because skipping it bit a real upgrade:

bundle install                 # the update may have changed the Gemfile
bin/rails db:migrate           # new template migrations
bin/rails runner true          # the app boots (catches configuration errors raised at boot)
bin/ci                         # THE gate: style, i18n health, audits, Brakeman, all tests, seeds
bin/railsfast doctor           # production readiness: every finding carries its fix
bin/railsfast mark-updated     # record the update so `status` tracks drift correctly

Also diff config/bundler-audit.yml against the release: if the template ships an advisory ignore, check whether a patched gem has shipped since. Inheriting a suppression when the patch exists means you're suppressing, not patched.

Then restart your dev server (bin/dev), click around, and land your update branch via PR like any other change.

What each release needs from you

Only the steps a merge cannot do for you. Do the ones for every release you cross, oldest first. The full notes are on each release (gh release view v1.4.0 -R railsfast/railsfast-base).

v1.15.2: quoted placeholders, lighter job forks

No steps. The sample's Turnstile placeholders are quoted, and its comments are v1.15.0's again, so bin/railsfast doctor no longer reports false formatting drift for credentials made from v1.15.0 or v1.15.1. bin/jobs warms the heap up before forking, and JOB_THREADS sets a job role's worker threads (see Background jobs). If you set JOB_THREADS, it must be a whole number of at least 1.

v1.15.1: sample credentials switch nothing on

No steps. Blank values and the abc123 placeholders of config/credentials.sample.yml now count as unset for Google sign-in and Turnstile, as they already did for push. The sample's unquoted Turnstile keys (0xabc123) reach the app as an integer, and are recognised too. If your production credentials still hold those placeholders, the "Continue with Google" button and the Turnstile widget disappear after the deploy instead of failing every sign-in; bin/railsfast doctor now reports the Turnstile placeholder as well.

v1.8.0: row-level security, API keys, job tripwires

Row-level security and API keys are opt-in. The seat-gate fix and the job tripwires apply on merge (the tripwires in production).

  1. bundle install takes organizations 0.9.0 (additive, no migration).
  2. Seat gate fix. A plan with limits :team_members, to: N made every join raise ArgumentError. The template's callbacks now read org.team_member_limit. If your config/initializers/organizations.rb kept its own seat callbacks, replace org.current_plan&.limit_for(:team_members) with org.team_member_limit.
  3. Job tripwires. config/recurring.yml gains queue_heartbeat_default, and config/allgood.rb a "Jobs run on every queue" check. If you run job roles with their own QUEUES, test/config/job_queues_test.rb fails until each role has a heartbeat queue: add queue_heartbeat_<queue> to config/recurring.yml, the queue to heartbeat_queues and the key to the required list in config/allgood.rb. The beat lives in Rails.cache, which must be shared by every process (Solid Cache, the default). See Monitoring.
  4. Guarded files (port by hand if you want them): row_level_security: false in config/railsfast/railsfast.yml (absent means off), and the commented second job role in config/deploy.yml.
  5. Optional: Row-level security and API keys.

v1.5.0: organizations 0.7

Personal workspaces, safe account closure and vocabularies (Organizations). The template's docs/organizations.md has the full "Upgrading from organizations 0.6" section; the short version:

  1. bundle install. If Gemfile.lock conflicts, take your side, then bundle lock --conservative --update organizations to take organizations 0.7.1, which is on rubygems (the Gemfile allows ~> 0.7.0, and the template's next release pins it).
  2. bin/rails db:migrate. Three template migrations add the personal-workspace link, the domain verification columns and a fix that lets the user who invited someone be deleted.
  3. bin/rails generate organizations:upgrade must print that organizations is up to date. Run it only after the migrations: before, it writes the gem's own migrations, which strong_migrations refuses.
  4. If you resolved a conflict in config/initializers/organizations.rb, it must keep config.record_personal_organizations = true, config.account_deletion_policy = :destroy_personal_organization and helper OrganizationsHelper in the public controller hook. Keep the template's new locale keys (bundle exec i18n-tasks missing), and the ast_matchers lines if you customized config/i18n-tasks.yml.
  5. Link the workspaces of accounts created before the upgrade with the reviewed backfill in docs/organizations.md. Until then those accounts behave as before.
  6. If you customized Users::RegistrationsController, the settings controller or a danger zone, read "Closing an account" in docs/organizations.md.

v1.4.0: the gem update pass

The largest upgrade of the 1.x line. Read the release notes in full; this is the checklist.

Before you boot:

  • pricing_plans refuses a grace window that never applies. Your config/initializers/pricing_plans.rb is guarded, so read every limits line: grace: is allowed only with after_limit: :grace_then_block and a duration of at least one second. Any other grace: (with :block_usage, with :just_warn, or with no after_limit:) raises PricingPlans::ConfigurationError at boot. bin/rails runner true finds any line you missed. See Pricing plans.
  • Keep the ruby-vips line. image_processing 2 no longer depends on it, so the template's Gemfile declares gem "ruby-vips", "~> 2.3", require: false right below image_processing. Without it the app still boots, but every avatar upload fails with a 500. Check that bundle exec ruby -e 'require "vips"' succeeds. See Attachments.
  • Copied admin views need @pagy renamed to @page. Only if you copied madmin's application/index.html.erb, fields/has_many/_show.html.erb or fields/nested_has_many/_show.html.erb: render madmin/shared/page_nav with page: where they rendered madmin/shared/pagination with pagy:. See Admin.
  • Rescue Pay::Error, not Stripe::StripeError. On Pay 12, a Stripe rescue around a Pay call (checkout, billing_portal, charge, subscribe, pause, invoice!, sync) misses every failure. The Stripe error is in error.cause. See Payments.

Upgrading:

  • bundle install, then bin/rails db:migrate for the feature grants table (three template migrations). Do not run the pricing_plans:grants or pricing_plans:passes generators, even though the gem's error messages suggest them.
  • Move assign_pricing_plan! and remove_pricing_plan! calls to override_pricing_plan! and clear_pricing_plan_override!: pricing_plans 0.8 removes the old ones. override_pricing_plan! requires a source: (for example override_pricing_plan!(:pro, source: "manual")).
  • Solid Queue batches (optional now, required by Solid Queue 2.0): add the batches migration to db/queue_migrate/ from the v1.4.0 release notes, run bin/rails db:migrate:queue in development, and commit it with the db/queue_schema.rb it dumps. Until you do, db:migrate rewrites db/queue_schema.rb without the batch tables: discard that diff. See Background jobs.
  • Zoneless schedules now follow config.time_zone. If config/application.rb sets a zone other than UTC, each schedule in config/recurring.yml without a zone now fires at that hour in that zone. Add the zone you mean (every day at 4am Europe/Lisbon) or set config.solid_queue.time_zone = nil.
  • db/schema.rb reorders its columns (Rails 8.1.4 lists them in migration order). Resolve a conflict by running bin/rails db:migrate, and commit the pure reorder on its own.
  • View tests that render twice now see only the last render.
  • Check your Stripe webhook endpoint's API version. Pay reads invoices in the shape Stripe introduced in 2025-03-31; on an older endpoint, it silently skips the payment-failed email and the invoice sync. Move the endpoint to a newer version in the Stripe dashboard (Developers, Webhooks). Pay's signing_secret credential takes a list, so both endpoints' secrets work during the switch.
  • Run one live test-mode checkout and one cancellation against your own Stripe account before you deploy, and check Settings and the admin show active, then canceled.
  • Review your own Pay and Stripe code for the renamed methods: has_trial? is trial_ends_at?, Pay::Payment#cancelled? is canceled?, Pay::Currency#subunit? is subunit.present? (with the opposite meaning), a duplicate processor_id raises ActiveRecord::RecordNotUnique, and stripe 20 renames payment_method_types to allowed_payment_method_types and Stripe::Reversal to Stripe::TransferReversal.
  • Custom organization roles: if a custom role holds invite_members or edit_member_roles, audit pending invitations and recent role changes (organizations 0.6.0 let such a role invite or promote an admin; 0.6.1 in v1.5.0 closes it).

v1.3.1

bundle install (nondisposable 0.6.2). An old adapter object in your config/initializers/nondisposable.rb still works; the :email_lookalike Symbol is the recommended form.

v1.3.0: email address checks

Sign-up and every email change now refuse throwaway and mistyped addresses (Email address checks).

  • bin/rails db:migrate (the nondisposable_source_snapshots table). Deploy outside the hour the nightly blocklist refresh runs (3am by default, config/recurring.yml).
  • Run bin/rails db:seed once if your disposable-domain table is empty, or Nondisposable::DomainListUpdater.update from a production console. Existing users are never re-judged until they change their email.

v1.2.0: deploy safety

  • Check SOLID_QUEUE_IN_PUMA in your config/deploy.yml: false, 0, off, no, f, n, or an unset or empty value turn it off, and any other value now starts the job supervisor in Puma. /healthcheck's "Background workers are alive" tells you if it did not.
  • The merge adds bin/check-deploy-secrets and a Kamal .kamal/hooks/pre-connect hook that stops a deploy with an empty secret (Deploying from clean checkouts). If you already had a pre-connect hook, chain the two.
  • config/deploy.yml and the Dockerfile are guarded: copy the commented second-origin role, the per-role memory limits and the optional PDF_TOOLS block from the template if you want them.

v1.1.0: push notifications

The web half of native push (Notifications; the template's docs/native/push.md has the detail).

  • bin/rails db:migrate runs four migrations. If the unique token index loses a race to a concurrent duplicate, run db:migrate again: it is retryable.
  • Set the APNs and FCM credentials under <env>.action_push_native, and the FCM project id with ANDROID_FIREBASE_PROJECT_ID or native.android.firebase_project_id in railsfast.yml (guarded, so add the key by hand).
  • Rebuild your iOS and Android apps from RailsFast Native 1.1.0 or later. Older installed apps keep working through Settings, Enable notifications.
  • An app that built push on its own: follow "If the app already built push" in docs/native/push.md.

v1.0.0

No application upgrade steps from v1.0.0-beta.12: only the version constant and docs/releasing.md changed. Do the guard sweep as usual.

From a beta or v0.x

Merge the latest release, then read the notes of every beta you cross (gh release view v1.0.0-beta.N -R railsfast/railsfast-base). The ones that need hands:

  • Any beta before beta.12: commits that change config/credentials.yml.enc now need CREDENTIALS_EDIT=1 (a pre-commit hook that bin/setup installs), and config/locales/{en,es}/madmin.yml carries the admin's words: a resource of your own gets a sentence under madmin.tables.<key>.
  • beta.10 or earlier: read the template's docs/pricing-plan-overrides.md before anything else. Those versions saved the stock Free plan as a manual override, and only you can tell which old rows were intended.
  • Before beta.5: translations arrive with the merge, but config/application.rb is guarded, so the i18n configuration does not. bin/railsfast doctor reports i18n-config-missing and prints the block to paste. Shipping in one language only? See Translations (i18n).

From v0.x, run bin/railsfast doctor first, not last: old apps sometimes moved ahead on their own, and doctor tells you what is actually missing. Then:

  1. Active Record encryption keys are required in production. config/application.rb fails closed on boot without them (doctor reports ar-encryption-keys). Generate them before deploying, or the first deploy dies at the health check:
    bin/rails db:encryption:init
    # copy the printed block into: EDITOR="cursor --wait" bin/rails credentials:edit
  2. Diff every guarded file as above, and delete the stale template_version: key from config/railsfast/railsfast.yml: the version now lives in lib/railsfast/version.rb.
  3. Ruby 4.0.5: rbenv install 4.0.5 or mise install, then bundle install. With a remote builder, update RUBY_VERSION in config/deploy.yml's builder args, and your Dockerfile's ARG RUBY_VERSION if you customized it.
  4. bin/rails db:migrate.
  5. If you customized Stimulus-driven markup: controllers moved under railsfast/ and app/ folders, so navbar, dropdown and slideover are now railsfast--layout--navbar, railsfast--ui--dropdown and so on. Update the data-controller attributes in views you own.