Configuration Patterns in Ruby
Configuration management is one of those problems every Ruby application eventually faces. How do you store database credentials? How do you switch between development and production settings? How do you keep secrets out of your codebase?
This guide walks through the most practical configuration patterns used in Ruby, from the simplest to the most robust.
Class Constants and Module-Level Config
The simplest approach is using constants directly in your classes or modules:
class MyApp
DATABASE_HOST = "localhost"
DATABASE_PORT = 5432
MAX_RETRIES = 3
end
MyApp::DATABASE_HOST # => "localhost"
This works for simple projects, but has limitations: no easy way to change values at runtime, no environment-specific overrides, and constants pollute the namespace.
A slightly better pattern uses a dedicated module:
module MyApp
module Config
DATABASE_HOST = ENV.fetch("DB_HOST", "localhost")
DATABASE_PORT = ENV.fetch("DB_PORT", "5432").to_i
end
end
MyApp::Config::DATABASE_HOST # => "localhost"
Using ENV.fetch with a default means your code still works without environment variables set, but you can override them when needed.
Environment Variables with ENV
Environment variables are the foundation of the 12-factor app methodology. Ruby gives you direct access through the ENV hash:
ENV["DATABASE_URL"]
ENV.fetch("API_KEY") # Raises KeyError if missing
ENV.fetch("API_KEY", "default-value") # Returns default if missing
ENV.fetch("PORT", nil) # Returns nil if missing
ENV.fetch("DEBUG", "false") == "true" # String comparison needed
A common pattern is to build a config object from environment variables:
class AppConfig
class << self
def database_url
ENV.fetch("DATABASE_URL")
end
def debug_mode?
ENV.fetch("DEBUG", "false") == "true"
end
def log_level
ENV.fetch("LOG_LEVEL", "info")
end
end
end
This approach centralizes all environment-based configuration in one place and provides a clean interface for the rest of your application.
YAML Configuration Files
For more complex configuration, YAML files are a popular choice. Ruby’s standard library includes YAML:
require "yaml"
config = YAML.load_file("config.yml")
# => {"database" => {"host" => "localhost", "port" => 5432}}
For safer parsing that prevents arbitrary code execution, always use YAML.safe_load with an allowlist:
config = YAML.safe_load(
File.read("config.yml"),
permitted_classes: [],
permitted_symbols: [],
aliases: false
)
A typical config.yml structure looks like:
# config.yml
development:
database:
host: localhost
port: 5432
log_level: debug
production:
database:
host: db.example.com
port: 5432
log_level: info
And to load the environment-specific config:
environment = ENV.fetch("RACK_ENV", "development")
config = YAML.safe_load(File.read("config.yml"), permitted_classes: [], permitted_symbols: [], aliases: false)
env_config = config[environment]
Environment-Specific Configs
A common pattern is separate files per environment:
# config/development.rb
configure :development do
set :database_host, "localhost"
set :debug, true
end
# config/production.rb
configure :production do
set :database_host, ENV.fetch("DATABASE_HOST")
set :debug, false
end
Ruby on Rails handles this automatically through config/ files. In a Rails app, config/environments/development.rb, config/environments/test.rb, and config/environments/production.rb contain environment-specific settings that are loaded based on RAILS_ENV.
For non-Rails apps, you can implement this pattern yourself:
def configure(env)
yield(configurations[env])
end
def configurations
@configurations ||= {
development: OpenObject.new,
test: OpenObject.new,
production: OpenObject.new
}
end
Config Objects with OpenStruct
Ruby comes with OpenStruct, which lets you create objects with arbitrary attributes on the fly:
require "ostruct"
config = OpenStruct.new(
host: "localhost",
port: 5432,
ssl: true
)
config.host # => "localhost"
config.port # => 5432
config.ssl # => true
config[:missing] # => nil
OpenStruct is convenient but slower than a Hash or a Struct due to its dynamic nature. It also allows setting any attribute, which can lead to typos that go unnoticed:
config = OpenStruct.new(host: "localhost")
config.host # => "localhost"
config.hoat # => nil (typo returns nil, not an error)
Struct-Based Config
A more type-safe approach uses Struct:
DatabaseConfig = Struct.new(:host, :port, :username, :password, :database)
config = DatabaseConfig.new(
ENV.fetch("DB_HOST"),
ENV.fetch("DB_PORT", "5432").to_i,
ENV.fetch("DB_USER"),
ENV.fetch("DB_PASSWORD"),
ENV.fetch("DB_NAME")
)
config.host # => ENV["DB_HOST"]
config.port # => Integer
Struct gives you named access like OpenStruct, but with better performance and clearer error messages when you try to access an uninitialized member.
Dry-Configurable
The dry-configurable gem from the dry-rb ecosystem provides a more powerful configuration DSL:
require "dry/configurable"
class MyApp
extend Dry::Configurable
setting :database_url
setting :max_retries, 3
setting :debug, false
end
MyApp.configure do |config|
config.database_url = ENV.fetch("DATABASE_URL")
config.debug = true
end
MyApp.settings.database_url # => value of DATABASE_URL
MyApp.settings.max_retries # => 3
Dry-configurable provides validation, nested settings, and lazy evaluation. It’s a great choice for libraries and larger applications.
Secrets Management
Never hardcode secrets in your codebase. Here are the common approaches:
Environment Variables (Simplest)
# config/initializers/secrets.rb
MyApp.secret_key = ENV.fetch("SECRET_KEY_BASE")
Rails Credentials
Rails 5.2+ provides encrypted credentials:
# Edit credentials with: rails credentials:edit
Rails.application.credentials.dig(:database, :password)
Rails.application.credentials.dig(:api, :key)
The credentials file is encrypted and stored in config/credentials.yml.enc. The decryption key lives in config/master.key (which should be in .gitignore).
Dotenv
The dotenv gem loads .env files into ENV:
# .env
DATABASE_URL=postgres://localhost/myapp
API_KEY=your-secret-key
# In your app
require "dotenv"
Dotenv.load
ENV["DATABASE_URL"] # => "postgres://localhost/myapp"
Always add .env to your .gitignore file. The .env.example file should list the required variables without actual values.
AWS Secrets Manager / HashiCorp Vault
For production at scale, use a secrets manager:
# Example with AWS Secrets Manager
require "aws-sdk-secretsmanager"
client = Aws::SecretsManager::Client.new(region: "us-east-1")
response = client.get_secret_value(secret_id: "myapp/production/database")
secrets = JSON.parse(response.secret_string)
This approach keeps secrets out of your environment variables entirely and provides audit logging.
12-Factor App Principles
The 12-factor app methodology codifies best practices for configuration:
-
Store config in the environment — Credentials and host-specific values belong in environment variables, not in code.
-
Strict separation of config from code — Your codebase should be identical across all environments (development, staging, production). Only environment variables should differ.
-
Keep the config store consistent — Use the same mechanism for all environments. Don’t use YAML files in development and environment variables in production.
# Good: everything comes from ENV
database_url = ENV.fetch("DATABASE_URL")
# Good: config object built from ENV
class Config
def self.database_url
ENV.fetch("DATABASE_URL")
end
end
# Avoid: mixing sources
database_url = if ENV["DATABASE_URL"]
ENV["DATABASE_URL"]
else
"sqlite://db.sqlite"
end
A Complete Example
Here’s how you might combine these patterns in a real application:
require "ostruct"
require "yaml"
class AppConfig
class << self
def load!(environment = ENV.fetch("RACK_ENV", "development"))
raw = YAML.safe_load(
File.read("config.yml"),
permitted_classes: [],
permitted_symbols: [],
aliases: false
)
env_config = raw.fetch(environment.to_s)
@config = OpenStruct.new(env_config.transform_values { |v| deep_symbolize(v) })
end
def config
@config ||= load!
end
private
def deep_symbolize(obj)
case obj
when Hash
obj.transform_keys(&:to_sym).transform_values { |v| deep_symbolize(v) }
when Array
obj.map { |v| deep_symbolize(v) }
else
obj
end
end
end
end
# Usage
AppConfig.load!(:production)
AppConfig.config.database.host # => "db.example.com"
AppConfig.config.database.port # => 5432
Choosing the Right Pattern
| Pattern | Best For |
|---|---|
| Class constants | Simple scripts, one-off tools |
| Module-level config | Small projects, prototypes |
| ENV directly | 12-factor apps, cloud deployments |
| YAML files | Multi-environment apps, non-sensitive config |
| OpenStruct | Quick config objects, small apps |
| Struct | Type-safe config, larger applications |
| Dry-configurable | Libraries, complex configuration needs |
| Credentials/Vault | Production secrets |
Start simple and evolve as your needs grow. Most applications benefit from combining approaches — YAML for environment-specific settings, environment variables for deployment-specific values, and a secrets manager for credentials.
See Also
- /guides/ruby-working-with-hashes/ — Hashes are often used alongside configuration patterns for storing and accessing config values
- /guides/ruby-yaml-safe-load/ — Safely parse YAML configuration files without security risks
- /guides/ruby-struct-guide/ — Struct provides a lightweight alternative to classes for data containers including config objects