Configuration Patterns in Ruby

· 6 min read · Updated March 31, 2026 · intermediate
ruby configuration design-patterns guides

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:

  1. Store config in the environment — Credentials and host-specific values belong in environment variables, not in code.

  2. Strict separation of config from code — Your codebase should be identical across all environments (development, staging, production). Only environment variables should differ.

  3. 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

PatternBest For
Class constantsSimple scripts, one-off tools
Module-level configSmall projects, prototypes
ENV directly12-factor apps, cloud deployments
YAML filesMulti-environment apps, non-sensitive config
OpenStructQuick config objects, small apps
StructType-safe config, larger applications
Dry-configurableLibraries, complex configuration needs
Credentials/VaultProduction 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