Applications today rely on an increasing number of external services, each requiring their own set of API keys and configuration parameters. There are several different approaches you can take to organizing these settings in a Rails app. Here's the process I went through in figuring out how to best store those configuration values so they stay organized and easy to manage.
Since Rails is all about convention over configuration, the
config directory is probably a good place to start. It's obviously where the configuration is supposed to go, but there a handful of places under that directory to choose from.
config/application.rb file is the starting point of the Rails configuration process. There are a bunch of framework-level parameters in here that are generated along with the rest of the Rails app, but it's probably best to leave this for those types of settings.
class Application < Rails::Application config.encoding = 'utf-8' end
If you can't configure it off the
#config method, I'd argue that it doesn't belong here.
There are files under
config/environments for each of the default environments in Rails (development, test, production). This breakdown provides us with a natural segregation among the various environments for settings that differ among them. For instance, in our staging environment we could use a service's sandbox credentials, so we don't end up polluting the production system or inadvertently emailing users while testing.
config/application.rb, though, these files are already full of Rails configuration parameters. While they may be a somewhat logical place to put other environment-specific settings, they've always felt like a better place for framework-level configuration to me. Using them for things like external service configuration is a bit awkward.
Creating separate files under
config/initializers for each library and service is a good alternative location. Segregating by service keeps the configuration well-organized and easy to maintain, but for settings that change among environments, you'll end up with a lot of conditional blocks like this:
if Rails.env.production? # production configuration elsif Rails.env.staging? # staging configuration else # local configuration end
One common way around this is to use a YAML file under
config. The initializer can then load and parse this YAML file, using the values under the key that matches the environment name.
config = YAML.load_file(Rails.root.join('config/redis.yml'))[Rails.env] Redis.current = Redis.new(host: config['host'], port: config['port'])
This brings with it its own set of issues, though, including the fact that you still have to either store your credentials in the repository or do the symlink shuffle during deployment.
This is where environment variables come into play. They're great because they let you write your configuration logic once, but also allow different values to be used depending on the environment or even a specific server instance. If you've ever used Heroku, you may have noticed that their platform makes heavy use of environment variables for configuring both the platform itself and most of the add-ons.
Airbrake.configure do |config| config.api_key = ENV['AIRBRAKE_API_KEY'] end
Another handy side effect I've realized is that, especially with Heroku, you can easily change the value of these variables and restart the server to change all sorts of settings without having to deploy a new version of the app. I've found myself extending this concept even beyond service configuration and into business rules. For instance, storing constant values as environment variables lets you tweak your business rules after the app has been deployed.
Say I'm launching a beta, and there's a fixed waiting period before users are sent their invitation to join.
class Invite WAITING_PERIOD = ENV['WAITING_PERIOD'].to_f.days end
I might start out with a value of zero to let users in right away, but if the beta starts picking up too many users, I can quickly dial back the load by increasing the wait time with a simple Heroku command:
heroku config:set WAITING_PERIOD=2
Unifying the Settings
This gets us pretty far. We now have a good place to store our configuration files and a nice method for storing values that may change, but I found myself with a growing number of these environment variables scattered throughout the
config and now
app directories. I began to add documentation to the
README in the root of the app, but this quickly began to get out of hand, and I felt like I was constantly at risk of leaving something out that the next developer who came along would be stuck scratching his head over.
So, I starting thinking about how to centralize these configuration parameters in a way that not only increased their proximity to one another, but also acted as self-documentation of sorts. Initially I thought a library like Redis::Settings might be helpful for this, but the more I thought about, the less it made sense to me to have to rely on a server for storing these settings.
This led me to just try doing the simplest thing that came to mind: a module full of module methods, with each method corresponding to a single configuration parameter.
module Settings extend self def app_name ENV['APP_NAME'] end end
Now, wherever I had previously referenced
ENV['APP_NAME'], it was just a matter of replacing it with
Settings.app_name. Not only does it read better, but it's now a method so I can easily swap out the source of that bit of configuration data with anything I like (even Redis!) if the need should arise.
WAITING_PERIOD example above can even encapsulate the type conversion:
module Settings extend self def waiting_period ENV['WAITING_PERIOD'].to_f.days end end
But I still needed to be able to handle a couple other requirements:
- default values for when the environment variable isn't defined
- an indication of which values are required in specific environments
The first is easy enough in Ruby. I chose to accomplish it through the use of a method argument default value.
module Settings extend self def waiting_period(default=0) (ENV['APP_NAME'] || default).to_f.days end end
The second, conditionally requiring some values to be defined based on the current environment, is simple enough, as well.
module Settings extend self RequiredValue = Class.new(StandardError) def app_name ENV['APP_NAME'] || required_in_production end def required_in_production raise RequiredValue if Rails.env.production? end private :required_in_production end
With this in place, the application will refuse to start without the
APP_NAME environment variable.
Using these techniques provides
I've been using this technique for all sort of magic values, and I'm happy with the results. It has satisfied all my initial requirements, and surprised me with some added benefits along the way, like the ability to encapsulating additional logic around the values.
I'm still using a single, monolithic module to encapsulate all the application's configuration, though, and at times this feels messy. I've resorted to using method prefixes like
smtp_ to keep things tidy, but this might be a good indication that additional modules would be useful.