rubyguides

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:

  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

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

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.

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