5 Unexpected Ways to Use Rails.app.creds

Rails 8.2 introduced Rails.app.creds as a unified way to access credentials from ENV and encrypted files. But buried in the implementation is something more powerful: CombinedConfiguration is a chain of responsibility for configuration. Each backend either returns a value or passes to the next.

This isn’t just credential lookup. It’s composable configuration middleware.

Here are five patterns that exploit this architecture.

1. Feature Flags Without a Service

Most apps don’t need LaunchDarkly. You need a way to toggle features that’s version-controlled and overridable in development.

# config/credentials.yml.enc
features:
  new_checkout: false
  dark_mode: true
  ai_summaries: false

Access them like any credential:

if Rails.app.creds.option(:features, :new_checkout, default: false)
  render_new_checkout
else
  render_legacy_checkout
end

The magic: ENV overrides credentials. Enable a feature locally without touching the encrypted file:

FEATURES__NEW_CHECKOUT=true bin/rails server

Need to test a feature in staging? Set the ENV var in your deployment config. No code changes, no credentials edit, no deploy.

Making it ergonomic

Wrap it in a helper:

# app/helpers/feature_flags_helper.rb
module FeatureFlagsHelper
  def feature?(name)
    Rails.app.creds.option(:features, name, default: false).to_s == "true"
  end
end

# Usage
<% if feature?(:new_checkout) %>
  <%= render "checkout/new" %>
<% end %>

You now have feature flags that are:

  • Version controlled (in credentials)
  • Environment-overridable (via ENV)
  • Zero external dependencies
  • Free

2. Developer Override Files

Every team has this problem: “How do I test with different credentials locally without committing them?”

Create a gitignored local override file:

# config/initializers/developer_overrides.rb
class DeveloperOverrides
  def initialize
    path = Rails.root.join(".secrets.local.yml")
    @config = path.exist? ? YAML.load_file(path).deep_symbolize_keys : {}
  end

  def require(*keys)
    option(*keys) || raise(KeyError, "Missing: #{keys.join('.')}")
  end

  def option(*keys, default: nil)
    value = keys.reduce(@config) { |h, k| h.is_a?(Hash) ? h[k] : nil }
    value.nil? ? default : value
  end

  def keys
    flatten_keys(@config)
  end

  def reload
    @config = YAML.load_file(Rails.root.join(".secrets.local.yml")).deep_symbolize_keys
  rescue Errno::ENOENT
    @config = {}
  end

  private

  def flatten_keys(hash, prefix = [])
    hash.flat_map do |k, v|
      v.is_a?(Hash) ? flatten_keys(v, prefix + [k]) : [prefix + [k]]
    end
  end
end

# Insert at highest priority
if Rails.env.development?
  Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
    DeveloperOverrides.new,
    Rails.app.envs,
    Rails.app.credentials
  )
end

Add to .gitignore:

.secrets.local.yml

Now any developer can create their own override file:

# .secrets.local.yml (gitignored)
stripe:
  secret_key: sk_test_my_personal_test_key
openai:
  api_key: sk-my-own-openai-key
features:
  experimental_ui: true

No more “I accidentally committed my API key” incidents. No more “let me temporarily edit credentials.yml.enc”. Each developer has their own sandbox.

3. Request-Scoped Configuration

What if admins could override configuration per-request? Useful for debugging, customer support, or A/B testing.

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :config_overrides
end

# lib/request_scoped_configuration.rb
class RequestScopedConfiguration
  def require(*keys)
    option(*keys) # Always returns nil to continue chain if not found
  end

  def option(*keys, default: nil)
    Current.config_overrides&.dig(*keys)
  end

  def keys
    Current.config_overrides&.keys || []
  end

  def reload
    # No-op, cleared per request
  end
end

Wire it up:

# config/initializers/credentials.rb
Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
  RequestScopedConfiguration.new,
  Rails.app.envs,
  Rails.app.credentials
)

Now in a controller:

class Admin::DebugController < ApplicationController
  before_action :require_admin

  def impersonate_config
    # Temporarily use Stripe test mode for this admin session
    Current.config_overrides = {
      stripe: { test_mode: true },
      features: { debug_panel: true }
    }
    redirect_back fallback_location: root_path, notice: "Debug mode enabled"
  end
end

The rest of your app doesn’t know or care. It just calls Rails.app.creds.option(:stripe, :test_mode) and gets the right value for the current request.

4. Audit Logging Wrapper

Compliance requirements often mandate logging access to sensitive credentials. With CombinedConfiguration, you can wrap any backend with logging:

# lib/audited_configuration.rb
class AuditedConfiguration
  def initialize(backend, logger: Rails.logger)
    @backend = backend
    @logger = logger
  end

  def require(*keys)
    log_access("require", keys)
    @backend.require(*keys)
  end

  def option(*keys, default: nil)
    log_access("option", keys)
    @backend.option(*keys, default: default)
  end

  def keys
    @backend.keys
  end

  def reload
    @backend.reload
  end

  private

  def log_access(method, keys)
    location = caller_locations(2, 1).first
    @logger.info({
      event: "credential_access",
      method: method,
      keys: keys.join("."),
      file: location.path,
      line: location.lineno,
      timestamp: Time.current.iso8601
    }.to_json)
  end
end

Wrap your production credentials:

# config/initializers/credentials.rb
if Rails.env.production?
  audited_creds = AuditedConfiguration.new(Rails.app.credentials)

  Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
    Rails.app.envs,
    audited_creds
  )
end

Every credential access is now logged:

{"event":"credential_access","method":"require","keys":"stripe.secret_key","file":"app/services/payment_service.rb","line":42,"timestamp":"2026-01-05T10:30:00Z"}

You can feed this to your SIEM, set up alerts for unusual access patterns, or satisfy auditors who want proof of access controls.

5. Scheduled Configuration

What if configuration could change based on time? Black Friday pricing. Holiday themes. Scheduled maintenance windows.

# lib/scheduled_configuration.rb
class ScheduledConfiguration
  Schedule = Data.define(:range, :config)

  SCHEDULES = [
    Schedule.new(
      range: Time.zone.parse("2026-11-27")..Time.zone.parse("2026-12-01"),
      config: {
        pricing: { discount_percent: 30 },
        features: { sale_banner: true },
        cache: { ttl: 60 } # Shorter cache during high traffic
      }
    ),
    Schedule.new(
      range: Time.zone.parse("2026-12-24")..Time.zone.parse("2026-12-26"),
      config: {
        features: { holiday_theme: true },
        support: { auto_reply: true }
      }
    )
  ]

  def require(*keys)
    option(*keys) || raise(KeyError, "Missing: #{keys.join('.')}")
  end

  def option(*keys, default: nil)
    active = SCHEDULES.find { |s| s.range.cover?(Time.current) }
    return default unless active

    keys.reduce(active.config) { |h, k| h.is_a?(Hash) ? h[k] : nil }
  end

  def keys
    SCHEDULES.flat_map { |s| s.config.keys }.uniq
  end

  def reload
    # Could reload from database or external source
  end
end

Wire it into the chain:

Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
  ScheduledConfiguration.new,  # Time-based overrides first
  Rails.app.envs,
  Rails.app.credentials
)

On Black Friday, Rails.app.creds.option(:pricing, :discount_percent) automatically returns 30. No deploy needed. When the schedule ends, it falls through to the default.

Your pricing code doesn’t need to know about Black Friday:

def apply_discount(price)
  discount = Rails.app.creds.option(:pricing, :discount_percent, default: 0)
  price * (1 - discount / 100.0)
end

The Pattern

All five examples share the same insight: CombinedConfiguration is middleware for configuration. The chain of responsibility pattern lets you:

  1. Layer behaviors without modifying existing code
  2. Compose features by ordering backends
  3. Separate concerns between storage, access control, and business logic

The order matters:

ActiveSupport::CombinedConfiguration.new(
  RequestScopedConfiguration.new,  # Highest priority: per-request overrides
  ScheduledConfiguration.new,       # Time-based rules
  DeveloperOverrides.new,           # Local development
  Rails.app.envs,                   # Environment variables
  AuditedConfiguration.new(         # Wrapped for logging
    Rails.app.credentials
  )
)

First non-nil value wins. Each backend can either handle the request or pass it down the chain.

Wrapping Up

Rails.app.creds looks like a simple convenience API. But CombinedConfiguration is a building block for sophisticated configuration systems:

  • Feature flags without external services
  • Developer sandboxes without credential conflicts
  • Request-scoped debugging
  • Compliance-ready audit logging
  • Time-based configuration without deploys

The best part? Your application code stays clean. It just calls Rails.app.creds.option(:whatever) and the configuration layer handles the complexity.

See PR #56404 for the implementation.