When building any Rails (SaaS) app, I often need to manage various configuration settings across different environments. Rails provides a powerful built-in feature through Rails.application.config_for
that lets you load environment-specific configurations from YAML files.
Rails configuration files support different scopes:
- shared: settings that apply to all environments;
- environment-specific: Settings that only apply to specific environments like
development
,production
, ortest
.
It automatically merges the shared
section with the current environment’s settings, that gives you a clean way to define common defaults while allowing environment-specific overrides.
While this is all nice. I don’t like the API all too much. To use it you have to write something like Rails.application.config.bot.api_key
. Ugh! That can be done better. Let’s look at how I have set this up in the past (and current) apps I built.
You can also check out the GitHub repo for the full implementation.
First a dedicated folder for all business-related configuration:
mkdir config/configurations
Now let’s create a thin wrapper around Rails’ configuration system in lib/configuration.rb
(files in my lib/
folder are/should be copy-pasteable throughout my apps):
module Config
module_function
def load!
settings_path = Rails.root.join("config", "configurations")
return unless File.directory?(settings_path)
Dir.glob(settings_path.join("*.yml")).each do |path|
file_name = File.basename(path, ".yml")
const_set(
file_name.camelize,
Rails.application.config_for("configurations/#{file_name}")
)
end
end
end
This simple module automatically loads all YAML files from your config/configurations
directory and creates constants based on the filename. For example, bot.yml
becomes Config::Bot
.
To enable this system, add the following to your config/application.rb
:
require_relative "../lib/configuration"
module YourApp
class Application < Rails::Application
config.load_defaults 8.0
Config.load!
# … rest of your configuration
end
end
Here’s an example bot configuration in config/configurations/bot.yml
:
shared:
api_key: <%= ENV.fetch("BOT_API_KEY", Rails.application.credentials.dig(:bot, :api_key)) %>
user_agent: "MyAwesomeBot/1.0"
timeout: 10
production:
endpoint: <%= ENV.fetch("BOT_ENDPOINT", "https://bot.mybot.com") %>
development:
endpoint: <%= ENV.fetch("BOT_ENDPOINT", "http://localhost:3000/bot") %>
I set up most keys to first fetch the environment for a key that follows the convention of the filename as a prefix. So for above example if I set BOT_API_KEY
it will use that instead of the fallback defined in Rails.application.credentials.dig(:bot, :api_key)
.
You can now access these settings anywhere (including in initializers!) in your app:
Config::Bot.api_key
Config::Bot.endpoint
Config::Bot.timeout
Much better, right?! ❤️
Here are some other configuration examples:
Email settings (config/configurations/email.yml
):
shared:
provider: <%= ENV.fetch("EMAIL_PROVIDER", "logger") %> # using the Courrier gem
production:
provider: <%= ENV.fetch("EMAIL_PROVIDER", "postmark") %>
api_key: <%= ENV.fetch("EMAIL_API_KEY", Rails.application.credentials.dig(:mailpace, :api_key)) %>
Stripe (config/configuration/stripe.yml
):
shared:
api_key: <%= ENV.fetch("STRIPE_API_KEY", Rails.application.credentials.dig(:stripe, :api_key)) %>
api_version: <%= ENV.fetch("STRIPE_API_VERSION", "2025-07-30.basil") %>
max_network_retries: <%= ENV.fetch("STRIPE_MAX_NETWORK_RETRIES", 2) %>
development:
default_price_id: price_in_development
production:
default_price_id: price_in_production
signing_secret_key: <%= ENV.fetch("STRIPE_SIGNING_KEY", Rails.application.credentials.dig(:stripe, :signing_secret)) %>
This approach gives a clean, organized way to manage all business logic’s configuration that has served me well. The automatic ENV variable fallbacks following the FILENAME_SETTING
convention make it easy to override settings in different environments without touching your code.
What do you think of this solution?