Ruby on Rails Feature Flags: Complete Guide with Flipper, Rollout and Custom Redis Implementation
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.
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 TouchRelated Articles
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