How to Build a Feature Flag System in Ruby on Rails That Protects Your Users

Learn how to build a feature flag system in Ruby on Rails from scratch — control rollouts, test in production safely, and ship with confidence.

How to Build a Feature Flag System in Ruby on Rails That Protects Your Users

Feature flags are a simple idea that can save you from a lot of trouble. Instead of deploying code that everyone can see right away, you hide it behind a switch. You turn that switch on for yourself first, then for a few test users, and finally for everyone. This way you can test in production without breaking things for your users. I have used this trick for years, and it never gets old. Let me walk you through building your own feature flag system in Ruby on Rails. I will keep everything plain and step by step, like I am sitting next to you.

Why you need a simple switch

Imagine you just finished a new checkout flow. You are proud of it. But you also know that your old checkout works fine. You do not want to risk a bad experience for all customers at once. So you hide the new code behind a flag. You turn it on for your team, test it live, fix bugs, then roll it out to one percent of users, then ten, then all. This is the safe way to ship.

I once worked on a team that deployed a big redesign without flags. It broke the payment page for an hour. After that, I swore I would never ship a feature without a switch again.

The simplest possible flag: environment variables

You can start with almost nothing. A boolean in your environment file works for a single developer or a small team.

# config/initializers/feature_flags.rb
ENV['NEW_CHECKOUT'] = 'true' if Rails.env.development?

Then in your code you check:

if ENV['NEW_CHECKOUT'] == 'true'
  # render new checkout
else
  # render old checkout
end

This is fine for learning. But it has problems. You cannot change the flag without redeploying. You cannot control who sees what. And if you forget to set the variable in production, the feature stays off. I have done that mistake, and it cost me a late night.

Moving flags to the database

A better approach stores flags in the database. You can change them at runtime through a simple admin page. I prefer a single model called FeatureFlag. Here is the migration:

# db/migrate/20250101000000_create_feature_flags.rb
class CreateFeatureFlags < ActiveRecord::Migration[7.0]
  def change
    create_table :feature_flags do |t|
      t.string :name, null: false
      t.boolean :enabled, default: false
      t.integer :percentage, default: 0
      t.timestamps
    end
    add_index :feature_flags, :name, unique: true
  end
end

The name is the key you will use in code. enabled is a simple on/off. percentage is for gradual rollouts to a random slice of users.

Now create the model:

# app/models/feature_flag.rb
class FeatureFlag < ApplicationRecord
  validates :name, presence: true, uniqueness: true

  def enabled_for_user?(user = nil)
    return false unless enabled

    return true if percentage >= 100
    return false if percentage <= 0

    # deterministic hash based on user id or random
    return true if user.nil? && percentage > 0

    user_id = user&.id || rand(100_000)
    (user_id % 100) < percentage
  end
end

The enabled_for_user? method uses a simple modulo trick. If a user has id 42 and the percentage is 10, then 42 % 100 = 42, which is not less than 10, so the flag is off for that user. This means the same user will always see the same experience as long as their id does not change. For anonymous users, we fall back to randomness.

A helper to check flags in views and controllers

You do not want to call FeatureFlag.find_by(name: 'new_checkout') everywhere. Create a little service object or a helper.

# app/helpers/feature_flag_helper.rb
module FeatureFlagHelper
  def feature_enabled?(name, user = nil)
    flag = FeatureFlag.find_by(name: name)
    return false unless flag

    flag.enabled_for_user?(user)
  end
end

Then in your application controller, include the helper so it is available everywhere:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include FeatureFlagHelper
  helper_method :feature_enabled?
end

Now in a view:

<% if feature_enabled?('new_checkout', current_user) %>
  <%= render 'checkouts/new_checkout' %>
<% else %>
  <%= render 'checkouts/old_checkout' %>
<% end %>

In a controller you can use it the same way:

class CheckoutsController < ApplicationController
  def show
    if feature_enabled?('new_checkout', current_user)
      # new logic
    else
      # old logic
    end
  end
end

This is already powerful. But you still have a problem: every check hits the database. That is slow on every request. We need caching.

Caching flag checks to avoid database queries

Rails has low-level caching. I store each flag in the cache for a few seconds. This way the database is not hammered.

# app/models/feature_flag.rb
class FeatureFlag < ApplicationRecord
  after_save :clear_cache

  def self.cached_flag(name)
    Rails.cache.fetch("feature_flag:#{name}", expires_in: 5.minutes) do
      find_by(name: name)
    end
  end

  def enabled_for_user?(user = nil)
    # same as before, but uses cached_flag inside helper
  end

  private

  def clear_cache
    Rails.cache.delete("feature_flag:#{name}")
  end
end

Update the helper to use the cached version:

module FeatureFlagHelper
  def feature_enabled?(name, user = nil)
    flag = FeatureFlag.cached_flag(name)
    return false unless flag

    flag.enabled_for_user?(user)
  end
end

Now when you change a flag in the admin panel, the cache is cleared automatically because of the after_save callback. This is fast and works well for most applications.

Adding an admin interface

You need a way to turn flags on and off without touching the database console. I build a simple admin controller under namespace Admin.

# config/routes.rb
namespace :admin do
  resources :feature_flags, only: [:index, :edit, :update]
end

Controller:

# app/controllers/admin/feature_flags_controller.rb
module Admin
  class FeatureFlagsController < ApplicationController
    before_action :require_admin

    def index
      @flags = FeatureFlag.all
    end

    def edit
      @flag = FeatureFlag.find(params[:id])
    end

    def update
      @flag = FeatureFlag.find(params[:id])
      if @flag.update(flag_params)
        redirect_to admin_feature_flags_path, notice: 'Flag updated.'
      else
        render :edit
      end
    end

    private

    def flag_params
      params.require(:feature_flag).permit(:enabled, :percentage)
    end
  end
end

Views are straightforward. A form with a checkbox for enabled and a number field for percentage. I wire them with simple Bootstrap or just plain HTML.

Here is the edit view:

<!-- app/views/admin/feature_flags/edit.html.erb -->
<h1>Edit <%= @flag.name.humanize %></h1>

<%= form_with model: [:admin, @flag] do |f| %>
  <div>
    <%= f.label :enabled %>
    <%= f.check_box :enabled %>
  </div>
  <div>
    <%= f.label :percentage %>
    <%= f.number_field :percentage, min: 0, max: 100 %>
  </div>
  <%= f.submit 'Save' %>
<% end %>

Add a simple require_admin method in your ApplicationController:

def require_admin
  redirect_to root_path unless current_user&.admin?
end

Now any admin can go to /admin/feature_flags and change flags in real time. No code change needed.

Gradual rollout with A/B testing

The percentage field lets you do gradual rollouts. But I found that random modulo based on user id is not perfect for anonymous users. Sometimes you want to ramp up traffic slowly. I use a background job that increases the percentage every hour until it reaches 100.

Create a simple rake task or a cron job:

# lib/tasks/feature_flags.rake
namespace :feature_flags do
  desc "Gradually enable a feature flag by 10% every 10 minutes"
  task :ramp_up, [:name] => :environment do |t, args|
    flag = FeatureFlag.find_by!(name: args[:name])
    return if flag.percentage >= 100

    new_percentage = [flag.percentage + 10, 100].min
    flag.update!(percentage: new_percentage)
    puts "#{flag.name} now at #{new_percentage}%"
  end
end

Schedule it with cron or Heroku Scheduler to run every 10 minutes.

A more advanced flag: targeting specific users or groups

Sometimes you want to turn a flag on for specific user ids, like your QA team. Add a user_ids column to the feature_flags table.

# migration
add_column :feature_flags, :user_ids, :text, array: true, default: []

Then update the model:

def enabled_for_user?(user = nil)
  return false unless enabled
  return true if user_ids.include?(user&.id)
  return true if percentage >= 100

  # gradual rollout for others
  user_id = user&.id || rand(100_000)
  (user_id % 100) < percentage
end

In the admin form you can allow a comma-separated list.

Testing feature flags in your specs

You need to make sure your flags work correctly in tests. I write a simple helper for RSpec.

# spec/support/feature_flags.rb
module FeatureFlagTestHelper
  def enable_feature(name)
    FeatureFlag.find_or_create_by!(name: name).update!(enabled: true, percentage: 100)
  end

  def disable_feature(name)
    FeatureFlag.find_or_create_by!(name: name).update!(enabled: false, percentage: 0)
  end
end

RSpec.configure do |config|
  config.include FeatureFlagTestHelper
end

In tests:

RSpec.describe CheckoutsController do
  scenario 'new checkout when flag is on' do
    enable_feature('new_checkout')
    get :show
    expect(response).to render_template('checkouts/new_checkout')
  end

  scenario 'old checkout when flag is off' do
    disable_feature('new_checkout')
    get :show
    expect(response).to render_template('checkouts/old_checkout')
  end
end

Remember to clean the database between tests, because flags persist.

Handling errors gracefully

If the feature_flags table does not exist yet (during migrations or first deployment), your app should not crash. Wrap the lookup in a rescue.

def feature_enabled?(name, user = nil)
  flag = FeatureFlag.cached_flag(name)
  return false unless flag

  flag.enabled_for_user?(user)
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
  false
end

I also add a fallback in the initializer:

if ActiveRecord::Base.connection.table_exists?(:feature_flags) rescue false
  # already covered
end

But usually the rescue is enough.

A note on naming and conventions

I use snake_case for flag names: new_checkout, dark_mode, beta_search. Keep them short and descriptive. If you have many flags, consider grouping them by module. For example, checkout_v2, search_autocomplete. Avoid long names like use_new_authentication_flow_with_oauth. Keep it simple.

Personally, I have a list in config/initializers/feature_flags.rb that seeds the database with default flags on deploy:

%w[
  new_checkout
  dark_mode
  beta_search
].each do |name|
  FeatureFlag.find_or_create_by!(name: name) do |flag|
    flag.enabled = false
    flag.percentage = 0
  end
end

This ensures every environment has the same flags.

Performance considerations

If you have a hundred flags, every request will issue a hundred cache lookups. That is fine if each cache lookup is fast (a few milliseconds). But if you have very high traffic, you can cache the entire flag set in one key.

def self.all_cached
  Rails.cache.fetch('feature_flags:all', expires_in: 5.minutes) do
    FeatureFlag.all.index_by(&:name)
  end
end

Then in the helper:

def feature_enabled?(name, user = nil)
  flags = FeatureFlag.all_cached
  flag = flags[name]
  return false unless flag

  flag.enabled_for_user?(user)
end

This reduces cache calls to one per request.

When to remove a flag

Once a feature is fully rolled out to everyone, you should remove the flag from the code. Otherwise you end up with dead code and confusing conditionals. I add a comment in the code like # REMOVE_FLAG: new_checkout after 2025-04-01 to remind myself. Then after the rollback period, I delete the old code path and the flag.

I once left a flag in production for three months. It confused a new developer who thought the feature was still experimental. Clean up early.

A complete example with background job for flag expiration

You can add an expires_at column to the flags so they auto-disable after a certain date.

add_column :feature_flags, :expires_at, :datetime

Add a nightly job:

class DisableExpiredFlagsJob < ApplicationJob
  def perform
    FeatureFlag.where('expires_at < ?', Time.current).update_all(enabled: false)
  end
end

Schedule it with cron.

Personal touches and lessons

The first feature flag system I built did not have caching. I wondered why the app slowed down. Then I added cache and it was fine. Later I forgot to clear the cache after updating a flag via a script, and the old value stuck for five minutes. I learned to always clear the cache in the model.

Another time I used random percentages without user ids. The same user would see the new feature on one page and the old on another because the random number changed. That was confusing. The deterministic modulo fix solved it.

Also, do not use flags for long-term experiments. If a feature stays behind a flag for months, it becomes a burden. Ship it fully or kill it.

Putting it all together

Here is the skeleton of your feature flag system:

  • A FeatureFlag model with name, enabled, percentage, user_ids, expires_at.
  • A helper method that checks the flag with caching.
  • A background job for gradual rollout.
  • A simple admin interface.
  • Test helpers.
  • A cron job to expire flags.

You can start with a single flag in development, then gradually add more. The code is small, easy to understand, and does not require any extra gems. I have used this approach in production for years. It pays for itself the first time you roll out a risky change without incident.

Now go write your own flag system. Start with one small switch, and you will never ship blindly again.


// Keep Reading

Similar Articles