Build a Feature Flag System in 50 Lines with Rails.app.creds
You don’t need LaunchDarkly. You don’t need Flipper. For most Rails apps, you need feature flags that are version-controlled, easy to override in development, and don’t require another service to manage.
Rails 8.2’s Rails.app.creds gives you exactly that. Here’s a complete feature flag system in under 50 lines of code.
The Core Insight
Rails.app.creds checks ENV first, then encrypted credentials. This means:
- Store your flags in
credentials.yml.enc(version controlled, reviewed in PRs) - Override with ENV variables anytime (testing, debugging, gradual rollouts)
No database. No external service. No API calls.
Step 1: Store Flags in Credentials
bin/rails credentials:edit
# config/credentials.yml.enc
features:
new_checkout: false
dark_mode: false
ai_summaries: false
beta_dashboard: false
These are your defaults. They’re encrypted, version-controlled, and require a PR to change.
Step 2: Create the Feature Flag Module
# app/models/concerns/feature_flags.rb
module FeatureFlags
extend self
def enabled?(flag_name)
value = Rails.app.creds.option(:features, flag_name, default: false)
ActiveModel::Type::Boolean.new.cast(value)
end
def disabled?(flag_name)
!enabled?(flag_name)
end
def enable!(flag_name)
Current.feature_overrides ||= {}
Current.feature_overrides[flag_name] = true
end
def disable!(flag_name)
Current.feature_overrides ||= {}
Current.feature_overrides[flag_name] = false
end
def with(flag_name, value)
old_value = Current.feature_overrides&.dig(flag_name)
enable!(flag_name) if value
disable!(flag_name) unless value
yield
ensure
if old_value.nil?
Current.feature_overrides&.delete(flag_name)
else
Current.feature_overrides[flag_name] = old_value
end
end
end
Step 3: Add Request-Scoped Overrides
For per-request flag control (testing, admin overrides), wire up Current:
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :feature_overrides
end
# lib/feature_override_configuration.rb
class FeatureOverrideConfiguration
def require(*keys)
option(*keys)
end
def option(*keys, default: nil)
return default unless keys.first == :features && keys.length == 2
Current.feature_overrides&.dig(keys.last)
end
def keys = []
def reload = nil
end
Update the credentials chain:
# config/initializers/credentials.rb
Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
FeatureOverrideConfiguration.new, # Request-scoped first
Rails.app.envs, # Then ENV
Rails.app.credentials # Then encrypted file
)
Step 4: Use It Everywhere
In controllers
class CheckoutController < ApplicationController
def show
if FeatureFlags.enabled?(:new_checkout)
render :new_checkout
else
render :legacy_checkout
end
end
end
In views
<% if FeatureFlags.enabled?(:dark_mode) %>
<body class="dark">
<% else %>
<body>
<% end %>
In models
class Order < ApplicationRecord
def calculate_shipping
if FeatureFlags.enabled?(:new_shipping_calculator)
NewShippingCalculator.calculate(self)
else
legacy_shipping_calculation
end
end
end
In tests
class CheckoutTest < ActionDispatch::IntegrationTest
test "new checkout flow" do
FeatureFlags.with(:new_checkout, true) do
get checkout_path
assert_select ".new-checkout-form"
end
end
test "legacy checkout flow" do
FeatureFlags.with(:new_checkout, false) do
get checkout_path
assert_select ".legacy-checkout-form"
end
end
end
Overriding Flags
In development
Override any flag without editing credentials:
# Enable one flag
FEATURES__NEW_CHECKOUT=true bin/rails server
# Enable multiple
FEATURES__NEW_CHECKOUT=true FEATURES__DARK_MODE=true bin/rails server
In staging/production
Set ENV variables in your deployment config:
# fly.toml, render.yaml, etc.
[env]
FEATURES__BETA_DASHBOARD = "true"
Per-request (admin tools)
class Admin::FeatureFlagsController < AdminController
def toggle
flag = params[:flag].to_sym
if FeatureFlags.enabled?(flag)
FeatureFlags.disable!(flag)
else
FeatureFlags.enable!(flag)
end
redirect_back fallback_location: admin_root_path
end
end
Advanced: Percentage Rollouts
Want to enable a feature for 10% of users?
# app/models/concerns/feature_flags.rb
module FeatureFlags
def enabled_for?(flag_name, user)
# Check if explicitly enabled/disabled first
return enabled?(flag_name) if explicitly_set?(flag_name)
# Check for percentage rollout
percentage = Rails.app.creds.option(:features, :"#{flag_name}_percentage", default: nil)
return enabled?(flag_name) unless percentage
# Consistent bucketing based on user ID
bucket = Digest::MD5.hexdigest("#{flag_name}-#{user.id}").first(8).to_i(16) % 100
bucket < percentage.to_i
end
private
def explicitly_set?(flag_name)
Current.feature_overrides&.key?(flag_name) ||
ENV["FEATURES__#{flag_name.to_s.upcase}"].present?
end
end
Configure the rollout:
# credentials.yml.enc
features:
new_checkout: false
new_checkout_percentage: 10 # 10% of users
Usage:
if FeatureFlags.enabled_for?(:new_checkout, current_user)
# 10% of users see this
end
The same user always gets the same result (consistent bucketing via MD5 hash).
Advanced: User Segment Targeting
Enable features for specific user segments:
module FeatureFlags
def enabled_for?(flag_name, user)
return true if user_in_allowlist?(flag_name, user)
return true if segment_enabled?(flag_name, user)
return enabled_by_percentage?(flag_name, user)
end
private
def user_in_allowlist?(flag_name, user)
allowlist = Rails.app.creds.option(:features, :"#{flag_name}_users", default: [])
allowlist.include?(user.id) || allowlist.include?(user.email)
end
def segment_enabled?(flag_name, user)
segments = Rails.app.creds.option(:features, :"#{flag_name}_segments", default: [])
segments.any? { |segment| user_in_segment?(user, segment) }
end
def user_in_segment?(user, segment)
case segment.to_sym
when :staff then user.staff?
when :beta then user.beta_tester?
when :premium then user.premium?
else false
end
end
end
# credentials.yml.enc
features:
new_checkout: false
new_checkout_users:
- 123 # User ID
- "cto@company.com" # Or email
new_checkout_segments:
- staff
- beta
new_checkout_percentage: 5 # Plus 5% of everyone else
Rollout strategy: staff first, then beta testers, then 5% of everyone, then increase percentage as confidence grows.
The Full Implementation
Here’s everything in one place:
# app/models/concerns/feature_flags.rb (46 lines)
module FeatureFlags
extend self
def enabled?(flag_name)
cast_boolean(Rails.app.creds.option(:features, flag_name, default: false))
end
def disabled?(flag_name) = !enabled?(flag_name)
def enabled_for?(flag_name, user)
return true if allowlisted?(flag_name, user)
return true if segment_match?(flag_name, user)
return percentage_match?(flag_name, user) if rollout_percentage(flag_name)
enabled?(flag_name)
end
def enable!(flag_name)
(Current.feature_overrides ||= {})[flag_name] = true
end
def disable!(flag_name)
(Current.feature_overrides ||= {})[flag_name] = false
end
def with(flag_name, value)
old = Current.feature_overrides&.dig(flag_name)
value ? enable!(flag_name) : disable!(flag_name)
yield
ensure
old.nil? ? Current.feature_overrides&.delete(flag_name) : Current.feature_overrides[flag_name] = old
end
private
def cast_boolean(value) = ActiveModel::Type::Boolean.new.cast(value)
def creds_option(flag, suffix) = Rails.app.creds.option(:features, :"#{flag}#{suffix}", default: nil)
def rollout_percentage(flag) = creds_option(flag, :_percentage)&.to_i
def allowlist(flag) = creds_option(flag, :_users) || []
def segments(flag) = creds_option(flag, :_segments) || []
def allowlisted?(flag, user)
list = allowlist(flag)
list.include?(user.id) || list.include?(user.email)
end
def segment_match?(flag, user)
segments(flag).any? { |s| user.try(:"#{s}?") }
end
def percentage_match?(flag, user)
Digest::MD5.hexdigest("#{flag}-#{user.id}").first(8).to_i(16) % 100 < rollout_percentage(flag)
end
end
Why This Beats a SaaS Solution
| Feature | This approach | SaaS |
|---|---|---|
| Cost | Free | $20-500/mo |
| Latency | Zero (in-memory) | API call |
| Version control | Yes (credentials) | Usually no |
| Code review | Yes (PRs) | Depends |
| Works offline | Yes | No |
| Complexity | Low | Medium-High |
| User targeting | Basic | Advanced |
| Analytics | DIY | Built-in |
For most apps, you don’t need real-time flag updates or complex targeting rules. You need flags that are easy to manage, don’t add latency, and don’t cost money.
When to Use a Real Feature Flag Service
This approach has limits. Consider a dedicated service when you need:
- Non-technical users to manage flags (product managers, etc.)
- Complex targeting rules (geography, device type, etc.)
- Real-time updates without deploys
- Built-in experimentation and A/B test analytics
- Audit logs and compliance features
But for most Rails apps? 46 lines of code and Rails.app.creds is all you need.
Wrapping Up
Rails 8.2’s Rails.app.creds with CombinedConfiguration is a perfect foundation for feature flags:
- Store defaults in encrypted credentials (version controlled)
- Override with ENV (no deploy needed)
- Override per-request (testing, admin tools)
- Add percentage rollouts and user targeting as needed
No external dependencies. No API latency. No monthly bill.
See PR #56404 for the Rails.app.creds implementation.