35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Ruby on Rails Feature Flags: Complete Guide with Flipper, Rollout and Custom Redis Implementation

Ruby on Rails Feature Flags: Complete Guide with Flipper, Rollout and Custom Redis Implementation

Roger Heykoop
Ruby on Rails, DevOps
Ship Rails features safely with feature flags. Flipper setup, custom Redis flags, percentage rollouts, CI/CD integration, testing strategies and common pitfalls.

Three weeks ago I watched a deploy take down a checkout flow for forty minutes. The team had the fix ready in ten, but their CI pipeline needed another thirty to get it to production. One toggle in Redis would have killed the feature in under a second. They didn’t have that toggle.

Feature flags are the difference between “we’ll have a fix deployed in an hour” and “it’s already off.” After nineteen years of Rails, I’ve seen every approach — YAML files, database-backed flags, Redis, Flipper, Rollout, and plenty of hand-rolled solutions that should never have been hand-rolled. Here’s what actually works, when to use what, and the traps that catch even experienced teams.

Why Feature Flags Matter More Than You Think

Feature flags decouple deployment from release. You push code to production, but the feature stays dark until you flip the switch. This sounds simple. The implications are not.

Without flags, every deploy is a release. Your CI/CD pipeline is the only thing between “merged to main” and “users see it.” That puts enormous pressure on code review and staging environments to catch everything. They won’t. Staging never matches production. Code review catches logic bugs, not load-related failures.

With flags, you deploy the code, enable it for your team first, then roll it out to 5% of users. If error rates spike, you disable it. No rollback, no hotfix branch, no emergency deploy. The code stays deployed — it just stops executing.

This changes how you think about risk. Features that would have waited for the next “big release” can ship incrementally. The metaprogramming techniques that power many flag libraries become practical tools rather than academic curiosities.

Flipper: The Right Default for Most Rails Apps

If you’re starting fresh, use Flipper. It’s mature, well-maintained, and covers 90% of what you need without writing any flag infrastructure yourself.

Setup

# Gemfile
gem "flipper"
gem "flipper-active_record"  # or flipper-redis, flipper-mongo
gem "flipper-ui"             # optional web dashboard

# terminal
bundle install
bin/rails generate flipper:active_record
bin/rails db:migrate

The migration creates a flipper_features and flipper_gates table. That’s your flag storage.

Basic Usage

# Enable globally
Flipper.enable(:new_search)

# Check
if Flipper.enabled?(:new_search)
  render_new_search
end

# Disable
Flipper.disable(:new_search)

In controllers, I wrap this in a helper:

# app/controllers/application_controller.rb
private

def feature?(name)
  Flipper.enabled?(name, current_user)
end
helper_method :feature?

Now views stay clean:

<% if feature?(:new_search) %>
  <%= render "search/redesigned" %>
<% else %>
  <%= render "search/current" %>
<% end %>

Actor-Based Targeting

Flipper’s real power is targeting specific users or groups. Your user model needs to respond to flipper_id:

class User < ApplicationRecord
  # Flipper uses this to identify actors
  # ActiveRecord models work out of the box — flipper_id defaults to
  # "User;#{id}" which is unique and stable
end

Now you can target precisely:

# Enable for one user (beta tester, internal QA)
Flipper.enable_actor(:new_checkout, User.find_by(email: "beta@example.com"))

# Enable for a percentage of users
Flipper.enable_percentage_of_actors(:new_checkout, 10)

# Enable for a group
Flipper.register(:staff) do |actor|
  actor.respond_to?(:staff?) && actor.staff?
end
Flipper.enable_group(:new_checkout, :staff)

Percentage rollouts use consistent hashing — the same user always gets the same result. No flickering between page loads.

Flipper UI

Mount the dashboard in your routes:

# config/routes.rb
Rails.application.routes.draw do
  constraints ->(req) { req.env["warden"].user&.admin? } do
    mount Flipper::UI.app(Flipper) => "/flipper"
  end
end

The constraint matters. Without it, anyone can toggle your features. I’ve seen production features disabled by crawlers hitting the Flipper UI because someone forgot authentication. Don’t be that team.

Custom Redis Implementation: When You Need Raw Speed

Flipper with the ActiveRecord adapter adds a database query per flag check. With caching, that’s usually fine. But if you’re checking flags in hot paths — middleware, API endpoints handling thousands of requests per second — you might want Redis directly.

# app/services/feature_flags.rb
class FeatureFlags
  REDIS_PREFIX = "feature_flags:"
  CACHE_TTL = 30 # seconds

  class << self
    def enabled?(flag, actor: nil)
      return false unless flag_active?(flag)
      return true if actor.nil?

      within_rollout?(flag, actor)
    end

    def enable(flag)
      redis.set("#{REDIS_PREFIX}#{flag}", "1")
      invalidate_cache(flag)
    end

    def disable(flag)
      redis.set("#{REDIS_PREFIX}#{flag}", "0")
      invalidate_cache(flag)
    end

    def set_rollout(flag, percentage)
      redis.hset("#{REDIS_PREFIX}#{flag}:config", "rollout", percentage)
      invalidate_cache(flag)
    end

    private

    def flag_active?(flag)
      Rails.cache.fetch("ff:#{flag}", expires_in: CACHE_TTL) do
        redis.get("#{REDIS_PREFIX}#{flag}") == "1"
      end
    end

    def within_rollout?(flag, actor)
      percentage = redis.hget("#{REDIS_PREFIX}#{flag}:config", "rollout").to_i
      return true if percentage >= 100
      return false if percentage <= 0

      actor_id = actor.respond_to?(:id) ? actor.id : actor.to_s
      Digest::SHA256.hexdigest("#{flag}:#{actor_id}").first(8).to_i(16) % 100 < percentage
    end

    def invalidate_cache(flag)
      Rails.cache.delete("ff:#{flag}")
    end

    def redis
      @redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1"))
    end
  end
end

Usage is identical:

if FeatureFlags.enabled?(:fast_search, actor: current_user)
  # new path
end

The 30-second cache TTL means flag changes propagate within half a minute. For most use cases, that’s fast enough. If you need instant propagation, drop the cache or use Redis pub/sub to invalidate across app servers.

When Redis Beats ActiveRecord

Scenario ActiveRecord Redis
Flag checks per request 1-3 10+
p99 latency requirement <100ms <10ms
Flag change frequency Minutes Sub-second
Infrastructure Already have Postgres Already have Redis

If you’re already running Redis for caching or Sidekiq, the marginal cost of flag storage is zero. If your only datastore is Postgres, adding Redis just for flags is overkill — use Flipper with ActiveRecord and call it done.

The Rollout Gem: Lightweight Alternative

Rollout is older and simpler than Flipper. It’s Redis-backed, has no UI, and does one thing: percentage-based feature rollouts.

# Gemfile
gem "rollout"

# config/initializers/rollout.rb
$rollout = Rollout.new(Redis.new)

# Activate for a percentage of users
$rollout.activate_percentage(:new_dashboard, 25)

# Check
if $rollout.active?(:new_dashboard, current_user)
  # show new dashboard
end

# Activate for a specific user
$rollout.activate_user(:new_dashboard, admin_user)

# Deactivate entirely
$rollout.deactivate(:new_dashboard)

Rollout requires your user object to respond to id. That’s the only contract.

I still use Rollout in smaller apps where Flipper’s UI and group system feel like overhead. For anything with more than two developers, Flipper’s audit trail and dashboard justify the extra setup.

Testing Feature Flags Without Losing Your Mind

Feature flags in tests are a source of flaky specs if you’re not careful. Flag state bleeds between examples, and suddenly your CI is red for reasons nobody can reproduce locally.

With Flipper

# spec/support/flipper.rb
RSpec.configure do |config|
  config.before(:each) do
    Flipper.features.each(&:disable)
  end
end

# In specs
describe "new search", type: :feature do
  before { Flipper.enable(:new_search) }

  it "shows redesigned results" do
    visit search_path(q: "rails")
    expect(page).to have_css(".search-v2")
  end
end

With Custom Flags

# spec/support/feature_flags.rb
module FeatureFlagHelpers
  def with_feature(flag, actor: nil)
    FeatureFlags.enable(flag)
    yield
  ensure
    FeatureFlags.disable(flag)
  end
end

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

# In specs
it "renders the new checkout" do
  with_feature(:new_checkout) do
    visit checkout_path
    expect(page).to have_content("Express checkout")
  end
end

Testing Both Paths

This is the part teams skip and regret. Every flag creates two code paths. Test both:

describe CheckoutController do
  context "with new checkout enabled" do
    before { Flipper.enable(:new_checkout) }

    it "processes payment through Stripe v2" do
      post :create, params: { amount: 1000 }
      expect(StripeV2::Charge).to have_received(:create)
    end
  end

  context "with new checkout disabled" do
    before { Flipper.disable(:new_checkout) }

    it "processes payment through legacy gateway" do
      post :create, params: { amount: 1000 }
      expect(LegacyGateway).to have_received(:charge)
    end
  end
end

CI/CD Integration

Feature flags change how your deployment pipeline works. Here’s what to automate.

Flag Validation in CI

Add a rake task that verifies flag references match what’s registered:

# lib/tasks/feature_flags.rake
namespace :feature_flags do
  desc "Check for references to unregistered flags"
  task validate: :environment do
    # Find all flag references in code
    flag_refs = `grep -rhoP 'Flipper\\.enabled\\?\\(:\\K[a-z_]+' app/`.split.uniq
    flag_refs += `grep -rhoP 'feature\\?\\(:\\K[a-z_]+' app/`.split.uniq

    registered = Flipper.features.map(&:name).map(&:to_s)

    orphans = flag_refs - registered
    if orphans.any?
      warn "WARNING: Referenced flags not registered in Flipper: #{orphans.join(', ')}"
      warn "Run: #{orphans.map { |f| "Flipper.add(:#{f})" }.join('; ')}"
    end
  end
end

Run this in CI after db:migrate:

# .github/workflows/ci.yml
- name: Validate feature flags
  run: bundle exec rake feature_flags:validate

Stale Flag Detection

Flags accumulate. Set expiration metadata and enforce it:

# lib/tasks/feature_flags.rake
namespace :feature_flags do
  desc "Report stale feature flags"
  task stale: :environment do
    Flipper.features.each do |feature|
      created = feature.created_at || Time.zone.parse("2026-01-01")
      age_days = (Time.current - created).to_i / 86400

      if age_days > 30 && feature.boolean_value
        puts "STALE (#{age_days}d, fully enabled): #{feature.name} — remove flag and keep code"
      elsif age_days > 60
        puts "ANCIENT (#{age_days}d): #{feature.name} — remove flag and probably the code too"
      end
    end
  end
end

Deploy-Time Flag Seeding

New flags should exist before the code that checks them goes live. Add flag seeding to your deploy process:

# db/seeds/feature_flags.rb
flags = %w[
  new_search
  express_checkout
  ai_recommendations
]

flags.each do |flag|
  Flipper.add(flag) unless Flipper.exist?(flag)
end
# In your deploy script or CI
- name: Seed feature flags
  run: bundle exec rails runner 'load "db/seeds/feature_flags.rb"'

This prevents the race condition where your app checks a flag that hasn’t been created yet. Flipper returns false for unknown flags, which is safe — but it’s better to be explicit.

Common Pitfalls

1. Flag Sprawl

I once audited a codebase with 87 active feature flags. Nobody knew which ones were still relevant. The if/else branches had if/else branches inside them. Testing was a joke — you’d need 2^87 combinations for full coverage.

Fix: Every flag gets an owner and a removal date. Put it in a comment next to the flag check:

# FLAG: new_search | owner: search-team | remove-by: 2026-05-01 | ticket: SRCH-442
if feature?(:new_search)

A linter or grep can catch flags past their date.

2. Flags in Migrations

Don’t use feature flags in database migrations. Migrations run once and are permanent. A flag that’s off today might be on tomorrow, but your migration already ran. Use zero-downtime migration techniques instead.

3. Nested Flag Dependencies

# Don't do this
if feature?(:new_checkout) && feature?(:stripe_v2) && !feature?(:legacy_override)
  # which combination of three booleans gets you here?
end

If flag B only makes sense when flag A is enabled, make that a single flag or use Flipper groups. Combinatorial flags are a maintenance nightmare.

4. Forgetting the Else Branch

# Dangerous: what happens when the flag is off?
def calculate_shipping
  if feature?(:new_shipping_calculator)
    NewShippingCalculator.call(order)
  end
  # nil — your shipping is free now. Congratulations.
end

Always handle both branches. If the flag-off path is “do nothing,” make that explicit with a comment.

5. Checking Flags in Loops

# Slow: hits cache/Redis on every iteration
orders.each do |order|
  if FeatureFlags.enabled?(:new_pricing, actor: order.user)
    # ...
  end
end

# Better: check once, partition
enabled_users = Set.new(users_with_feature(:new_pricing).pluck(:id))
orders.each do |order|
  if enabled_users.include?(order.user_id)
    # ...
  end
end

Making the Choice

Team/App Size Recommendation
Solo/prototype YAML config or ENV vars
Small team, simple needs Rollout gem + Redis
Most Rails apps Flipper + ActiveRecord
High-traffic, latency-sensitive Flipper + Redis adapter, or custom Redis
Enterprise, audit requirements Flipper Cloud or LaunchDarkly

Start simple. You can always migrate from Rollout to Flipper — the interface is nearly identical. Going from YAML to database-backed flags is a bigger jump, so make that move early if you expect to need percentage rollouts.

Feature flags aren’t just a deployment convenience. They’re a fundamentally different way of shipping software. The team that can enable a feature for 1% of users on Monday, watch the metrics, expand to 10% on Wednesday, and go to 100% on Friday ships with a confidence that no amount of staging environment testing can match.

Need help implementing feature flags in your Rails app, or cleaning up a codebase buried under years of stale toggles? TTB Software has been shipping Rails to production for nineteen years. We’ve seen every flag pattern — and every flag disaster.

Frequently Asked Questions

Which feature flag gem should I use for a new Rails 8 app?

Flipper. It has the broadest adapter support (ActiveRecord, Redis, Mongo), a built-in web UI, actor and group targeting, and active maintenance. Unless you have a specific reason to go custom, Flipper saves you from reinventing the wheel.

Can I use feature flags with Rails fragment caching?

Yes, but you need to include the flag state in the cache key. Otherwise cached fragments will serve stale content when a flag changes:

<% cache [current_user, feature?(:new_layout), "sidebar"] do %>
  <%= render feature?(:new_layout) ? "sidebar_v2" : "sidebar" %>
<% end %>

How do feature flags affect performance in production?

With an ActiveRecord adapter, each flag check is a database query (typically <1ms with proper indexes). With Redis, it’s a network round-trip (~0.1-0.5ms). With in-memory caching on top, it’s effectively free after the first check. Most apps can check 10+ flags per request without measurable impact.

Should I feature-flag API endpoints differently than UI features?

The check mechanism is the same, but the response matters. For UI, you show or hide elements. For APIs, return a meaningful response when the feature is off — a proper error code, not a 500. Document flagged endpoints in your API changelog so consumers aren’t surprised.

#rails #feature-flags #flipper #rollout #redis #ci-cd #ruby #deployment
R

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation