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 flexible.
TL;DR
Start with the simplest source that fits the job: constants for tiny scripts, ENV for deployment-specific values, YAML for structured settings, and a dedicated config object when the app needs a clear interface. Keep secrets out of source files, prefer explicit defaults, and use one configuration style consistently so the app behaves the same in each environment.
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"
Constants handle simple projects well enough, but they come with limitations: no easy way to change values at runtime, no environment-specific overrides, and constants pollute the namespace. A single script that only ever connects to one database will never notice these restrictions, but a web app deployed to staging and production will hit them almost immediately.
Its main strength is readability. The downside is that it quickly becomes too rigid if the application needs to change per environment. That makes it a good starting point, but usually not the final shape for a growing app.
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.
That little default is important because it keeps local development smooth. A missing variable should be a deliberate choice, not a surprise, and ENV.fetch makes that boundary obvious. When the app grows, the same pattern still works because the values remain centralized.
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
Once you know that ENV is always available and always returns a string, the next step is deciding where to put the fetch calls. If you scatter ENV["KEY"] across many files, you lose track of which variables the application expects and you give up the ability to set defaults in one place. That is why most projects with more than a handful of settings graduate from direct ENV access to a configuration object.
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.
Centralizing the access points matters because it gives you one place to audit, one place to test, and one place to change when deployment needs shift. It also keeps the rest of the code from scattering ENV.fetch calls everywhere.
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}}
The plain YAML.load_file call is the shortest path to a result, and for a trusted local file in a script you control, it is often enough. When that file comes from a source you do not control, or when the application is long-lived enough that someone might add a tag later, load_file is not safe.
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
)
The safe_load version avoids deserializing arbitrary Ruby objects, which means an attacker who can write to your YAML file cannot trick the parser into creating arbitrary classes. This is a narrow-but-real risk in multi-tenant setups where configuration files might pass through a shared file system. The result of safe_load is the same hash you would get from load_file, but it rejects any YAML directives that could trigger code execution.
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
With the YAML file written, you can load only the section that matches the current environment. Most Ruby frameworks pick this up from RACK_ENV or RAILS_ENV, and the same convention works in standalone scripts. The important part is that the file stays checked into the repository while secrets stay separate.
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]
YAML is a good fit when the structure is more important than the source. It keeps nested data readable, which makes it useful for settings that have several grouped values. The tradeoff is that you now have a file to parse and validate, so safe_load is the right default.
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. Each file sets the values that make sense for that context, and Rails picks the right one at boot time.
The same idea works outside of Rails if you follow the same conventions. 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
This pattern is useful when the app wants a slightly different shape per environment but still needs one obvious entry point for loading it. It is especially handy in small frameworks or command-line tools where a full configuration library would be too much.
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:
Use it when you need speed of writing, not speed of execution. It is easy to prototype with because you do not need to define every field up front, but that flexibility can hide mistakes. In longer-lived code, a more explicit object usually pays off.
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. Unlike OpenStruct, a Struct class locks its members at definition time so you cannot accidentally read a misspelled field. That makes it a natural upgrade once you know exactly which settings the application needs and you want the Ruby runtime to catch mistakes for you.
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.
That makes Struct a useful middle ground. It stays small, but it also makes the shape of the configuration visible in the class definition. If the object should only hold a few known fields, Struct keeps that contract obvious.
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.
The main reason to reach for a gem like this is clarity at scale. Once the configuration has many settings, nested groups, or validation rules, a dedicated DSL can be easier to maintain than ad hoc helper methods. The extra dependency only makes sense when that extra structure is actually needed.
Secrets management
Never hardcode secrets in your codebase. Here are the common approaches:
That rule is worth repeating because secrets are the one part of configuration that should almost never live in source control. A good configuration system keeps values flexible, but secrets deserve an extra layer of protection so accidental commits do not become security incidents.
Environment variables (simplest)
# config/initializers/secrets.rb
MyApp.secret_key = ENV.fetch("SECRET_KEY_BASE")
Rails Credentials
Rails 5.2+ provides encrypted credentials. This feature bundles all secrets into a single encrypted YAML file that can safely live in version control, with the master key held outside the repository. The approach works well for Rails apps deployed to platforms like Heroku or Fly.io where you set the master key as a single environment variable.
# 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). Encrypted credentials are a solid default for Rails applications, but they tie you to the Rails ecosystem and can be awkward for non-Rails tools that also need secrets.
Dotenv
The dotenv gem loads .env files into ENV. It is a lighter alternative that works across any Ruby project, Rails or not. Dotenv reads key-value pairs from a local file and pushes them into the process environment at startup, which means you can share the .env.example template while keeping the real values local.
# .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. This is the minimum security bar for any project that uses environment variables for secrets. A second developer who clones the repo should be able to run the app after copying .env.example and filling in their own keys.
For applications running across multiple machines or with compliance requirements, moving secrets into a managed service gives you more control over who can read them and when they were last accessed.
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.
It is a stronger fit for production systems that need rotation, access control, and a paper trail. If the app handles sensitive data or runs across several services, the extra ceremony is often worth it.
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
The three rules above cover most of what you need to keep configuration clean. When every setting traces back to the environment, you can deploy the same artifact to any host without worrying about file system differences or hardcoded paths. That consistency is the biggest practical payoff of 12-factor configuration.
a complete example
Here is how you might combine these patterns in a real application, pulling together YAML parsing, environment-aware loading, and a config object:
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.
The most important thing is consistency. A configuration scheme is only helpful if other developers can predict where a value comes from and how to change it. Once that rule is clear, the codebase becomes much easier to operate and much easier to move between environments.
Choosing the right pattern
If you only need a few settings, constants or a small module are fine. If the values come from the deployment environment, prefer ENV. If the configuration is nested or shared across many settings, use YAML or a config object. If the app needs stronger validation or a large number of options, a dedicated configuration library is worth the extra setup. For an alternative approach to data containers, see the Struct guide for lightweight configuration objects.
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