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:

  1. Store your flags in credentials.yml.enc (version controlled, reviewed in PRs)
  2. 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

FeatureThis approachSaaS
CostFree$20-500/mo
LatencyZero (in-memory)API call
Version controlYes (credentials)Usually no
Code reviewYes (PRs)Depends
Works offlineYesNo
ComplexityLowMedium-High
User targetingBasicAdvanced
AnalyticsDIYBuilt-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:

  1. Store defaults in encrypted credentials (version controlled)
  2. Override with ENV (no deploy needed)
  3. Override per-request (testing, admin tools)
  4. 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.