AnywayAppConfig

Schema-driven application config built on top of anyway_config.

anyway_config does the heavy lifting of loading values from YAML and ENV. anyway_app_config adds a small DSL on top for describing app config with typed attributes, defaults, required fields, and nested objects (single or array). Configs can be used as plain instances or as a singleton.

Installation

Add to your Gemfile:

gem "anyway_app_config"

Then run bundle install.

Defining a config

Inherit from AnywayAppConfig::Config and describe attributes with the attribute DSL:

require "anyway_app_config"

class AppConfig < AnywayAppConfig::Config
  config_name "app_config"
  env_prefix  "APP"

  attribute :deploy_env, type: :string, required: true
  attribute :version,    type: :string, default: "unknown"
  attribute :commit_sha, type: :string, default: "000000"

  attribute :sentry, required: true do
    attribute :dsn,         type: :string, default: ""
    attribute :environment, type: :string, required: true
    attribute :server_name, type: :string, required: true
    attribute :tags,        type: :hash,   default: {}
  end

  attribute :prometheus, required: true do
    attribute :enabled,        type: :boolean, default: false
    attribute :host,           type: :string,  default: "localhost"
    attribute :port,           type: :integer, default: 9394
    attribute :default_labels, type: :hash,    default: {}
  end
end

attribute accepts:

option meaning
type: type id from anyway_config's registry (:string, :integer, :float, :boolean, :date, :datetime, :uri, :hash, …), or any object responding to #call(value)
array: when true, value is an array of type (or nested objects)
default: default value (defaults to nil, or [] when array: true)
required: validate that the attribute is present and not empty
block defines a nested config object (see below)

Nested attributes

Pass a block to define a nested config. The DSL builds a child config class that inherits from AnywayAppConfig::Config, exposes the same DSL, and is exposed as a constant (e.g. AppConfig::SentryCfg).

Combine with array: true to get a list of nested objects:

class AppConfig < AnywayAppConfig::Config
  config_name "app_config"

  attribute :servers, array: true do
    attribute :host, type: :string, required: true
    attribute :port, type: :integer, default: 80
  end
end

The :hash type

AnywayAppConfig::Config registers a :hash type on a per-class type registry. It accepts any Hash value as-is and raises ArgumentError for non-hash values. Anyway's global TypeRegistry.default is not mutated.

Loading config

config = AppConfig.load!  # all sources merged, contained values frozen
config.sentry.environment
config.servers.first.host

load! returns a new instance every call (no caching). Sources are loaded through anyway_config (YAML + ENV by default).

Freezing

On load! (and deep_freeze_values!) the config freezes all of its contained values — Arrays, Hashes, and scalars — so the loaded data is effectively immutable. The Config instance itself and any nested Config objects are not frozen, so RSpec stubs keep working:

allow(AppConfig.sentry).to receive(:dsn).and_return("stubbed")

If a specific value class can't be frozen (e.g. it holds mutable state like a cache client or logger), add it to skip_freeze_classes to exclude it from the freeze walk (matched via is_a?, inherited by subclasses):

class AppConfig < AnywayAppConfig::Config
  self.skip_freeze_classes = [Logger, SomeCacheClient]
  # ...
end

Explicit config path

By default, anyway_config looks for config/<config_name>.yml (or whatever Anyway::Settings.default_config_path resolves to). To point at a specific YAML file, pass config_path: to new/load!:

AppConfig.load!(config_path: "/etc/myapp/app_config.yml")

config_path: also forwards through Singleton#load!.

For a per-class default, set explicit_config_path on the class (inherited by subclasses, overridden by a per-call config_path:). It accepts a String or Pathname directly:

class AppConfig < AnywayAppConfig::Config
  self.explicit_config_path = Rails.root.join('config', 'app_config.yml')
  # ...
end

Or a Proc for cases where the path depends on runtime state (called every time a new instance is built):

class AppConfig < AnywayAppConfig::Config
  self.explicit_config_path = -> { "/etc/myapp/#{ENV.fetch('APP_ENV')}.yml" }
  # ...
end

Anyway's other built-in mechanisms (<CONFIG_NAME>_CONF env var, Anyway::Settings.default_config_path lambda) still work — config_path: and explicit_config_path just give you a class-scoped, code-driven option.

Choosing a YAML loader

By default the :yml loader decides whether your YAML is environment-keyed (development: / production: sections) or flat by inspecting the file content and global state. To make that explicit, the gem registers two extra loaders you can select via configuration_sources:

  • :flat_yml — always reads top-level keys, ignores environment sections.
  • :env_yml — always reads the section matching Anyway::Settings.current_environment; raises if it is not set.
class Credentials < AnywayAppConfig::Config
  config_name 'credentials'
  self.configuration_sources = [:flat_yml, :env]   # flat YAML + ENV overrides
end

class AppConfig < AnywayAppConfig::Config
  config_name 'app_config'
  self.configuration_sources = [:env_yml, :env]    # env-keyed YAML + ENV overrides
end

Pick one YAML loader per class — :yml, :flat_yml, and :env_yml all read the same file, so listing more than one just loads it repeatedly. The default configuration_sources is untouched, so classes that don't opt in keep using :yml.

Singleton mode

Include AnywayAppConfig::Singleton to get a class-level singleton with class-level access to all instance methods:

class AppConfig < AnywayAppConfig::Config
  include AnywayAppConfig::Singleton
  # ...
end

AppConfig.load!                # values frozen, instance cached on the class
AppConfig.deploy_env           # delegates to instance
AppConfig.sentry.environment   # delegates to instance
AppConfig.instance             # the cached instance
AppConfig.loaded?              # true / false

AppConfig.load!                # raises AnywayAppConfig::AlreadyLoadedError
AppConfig.foo                  # raises AnywayAppConfig::NotLoadedError if not loaded

The singleton is intentionally strict — there is no reload!. To re-read config, restart the process.

Note: class-level delegation goes through method_missing, so attribute names that clash with existing Class methods (name, class, send, …) are not delegated — pick non-clashing names.

YAML and ENV

Loading is provided by anyway_config. A typical config/app_config.yml:

development: &dev
  deploy_env: "development"

  sentry:
    dsn: ""
    environment: "development"
    server_name: "denis-t.localhost"
    tags:
      custom: "tag"

  prometheus:
    enabled: false
    host: "localhost"
    port: 9394

test:
  <<: *dev
  deploy_env: "test"

production:
  <<: *dev

ENV vars use the prefix declared via env_prefix, e.g. APP_DEPLOY_ENV, APP_SENTRY__ENVIRONMENT. See the anyway_config docs for the full source list and naming rules.

Rails

There is no Railtie — Rails already exposes Rails.configuration as the canonical place to hang application-wide settings, so wiring through it is three lines. The pattern:

1. Define the config class in config/app_config.rb:

require "anyway_app_config"

class AppConfig < AnywayAppConfig::Config
  config_name "app_config"
  env_prefix  "APP"

  attribute :deploy_env, type: :string, required: true
  attribute :version,    type: :string, default: "unknown"

  attribute :sentry, required: true do
    attribute :dsn,         type: :string, default: ""
    attribute :environment, type: :string, required: true
  end
end

2. (Optional) Add config/app_config.yml:

development:
  deploy_env: "development"
  sentry:
    environment: "development"

production:
  deploy_env: "production"
  sentry:
    environment: "production"

Any value can be overridden via ENV using the env_prefix (APP above). Examples:

  • APP_DEPLOY_ENV=stagingAppConfig#deploy_env
  • APP_SENTRY__DSN=https://...AppConfig#sentry.dsn (double underscore for nesting)
  • APP_VERSION=1.2.3AppConfig#version

ENV wins over YAML. See the anyway_config docs for the full source list and naming rules.

3. Load and assign in config/application.rb:

require_relative "app_config"

module MyApp
  class Application < Rails::Application
    config.app_config = AppConfig.load!
    # ...
  end
end

4. Use it anywhere via Rails.configuration:

Rails.configuration.app_config.deploy_env
Rails.configuration.app_config.sentry.dsn

AppConfig.load! returns an instance with frozen values, so Rails.configuration.app_config is safe to read from any thread. If you want class-level access (AppConfig.deploy_env) instead, use Singleton mode and just call AppConfig.load! in config/application.rb without assigning it to config.app_config.

Replacing Rails.application.credentials (unencrypted)

Rails 7.2+ encrypts config/credentials.yml.enc by default. If your secrets are already injected by your deploy pipeline (Helm, Kubernetes secrets, CI/CD vault, ENV) you don't need encryption-at-rest in the repo — and the encrypted credentials flow becomes pure overhead. anyway_app_config is a drop-in replacement: typed, required-checked, value-frozen, and ENV-overridable.

1. Define the credentials class in config/credentials.rb:

require "anyway_app_config"

class Credentials < AnywayAppConfig::Config
  config_name "credentials"
  env_prefix  ""   # no prefix — read raw ENV like SECRET_KEY_BASE, DATABASE_PASSWORD
  self.configuration_sources = [:yml, :env]   # see note below

  attribute :secret_key_base,       type: :string, required: true
  attribute :database_password,     type: :string, required: true
  attribute :aws_access_key_id,     type: :string, default: ""
  attribute :aws_secret_access_key, type: :string, default: ""
end

Pin configuration_sources explicitly. Under Rails, anyway_config registers a :credentials loader that reads from Rails.application.credentials — exactly the thing you're trying to replace. If you don't override configuration_sources, your shiny new Credentials class will silently merge values back in from the encrypted credentials file (or fail to boot if RAILS_MASTER_KEY is missing). Setting self.configuration_sources = [:yml, :env] keeps loading limited to YAML + ENV and removes the dependency on the Rails credentials loader.

An empty env_prefix matches ENV var names directly against attribute names (uppercased), so SECRET_KEY_BASE populates :secret_key_base. Useful for secrets that have well-known unprefixed names — SECRET_KEY_BASE, DATABASE_URL, RAILS_MASTER_KEY. Other ENV vars are simply ignored unless they match a declared attribute. Caveat: pick attribute names carefully — if you declare attribute :path or :home with empty prefix, you'll pick up PATH/HOME from the shell. When in doubt, keep a prefix.

2. Add config/credentials.yml (gitignore it, or commit only the dev/test branches and inject production values via ENV):

development:
  secret_key_base: "dev-only-not-a-real-secret"
  database_password: "postgres"

test:
  secret_key_base: "test-only-not-a-real-secret"
  database_password: "postgres"

production:
  # Leave empty here and inject via ENV (SECRET_KEY_BASE=..., etc) at deploy time.
  secret_key_base: ""
  database_password: ""

3. Wire it in config/application.rb:

require_relative "credentials"

module MyApp
  class Application < Rails::Application
    config.load_defaults 8.1

    Rails.application.credentials = Credentials.load!
    # Rails 7.2+ generates a dynamic secret_key_base in dev/test when one
    # is not set. Pin it from credentials so it stays stable across boots.
    # See Rails::Application::Configuration#generate_local_secret?
    config.secret_key_base = Rails.application.credentials.secret_key_base
  end
end

Assigning directly inside the class body works because Rails.application is already available by the time config/application.rb is evaluated. If you need the assignment deferred (e.g. credentials depend on something set up by an initializer or another before_configuration hook), wrap it in config.before_configuration { ... } instead.

4. Use it like the standard credentials object:

Rails.application.credentials.secret_key_base
Rails.application.credentials.database_password

ENV overrides use raw names because of the empty env_prefix: SECRET_KEY_BASE=..., DATABASE_PASSWORD=....

Trade-off: you lose encryption-at-rest. Only do this if credentials.yml is gitignored or contains placeholders only, with real values injected via ENV at deploy time. The win is that secrets become typed, required-checked, and centrally declared — instead of an opaque EncryptedConfiguration blob that silently returns nil for typos.

Inline form: AnywayAppConfig.build

For small Rails apps where dedicated config/app_config.rb and config/credentials.rb files feel like overkill, declare configs inline in config/application.rb with AnywayAppConfig.build:

module MyApp
  class Application < Rails::Application
    Rails.application.credentials = AnywayAppConfig.build(load: true) do
      config_name "credentials"
      env_prefix  ""   # no prefix — read raw ENV like SECRET_KEY_BASE

      attribute :secret_key_base,       type: :string, required: true
      attribute :database_password,     type: :string, required: true
      attribute :aws_access_key_id,     type: :string, default: ""
      attribute :aws_secret_access_key, type: :string, default: ""
    end
    config.secret_key_base = Rails.application.credentials.secret_key_base

    config.app_config = AnywayAppConfig.build(load: true) do
      config_name "app_config"
      env_prefix  "APP"

      attribute :deploy_env, type: :string, required: true
      attribute :version,    type: :string, default: "unknown"

      attribute :sentry, required: true do
        attribute :dsn,         type: :string, default: ""
        attribute :environment, type: :string, required: true
      end
    end
  end
end

AnywayAppConfig.build(&block) returns an anonymous AnywayAppConfig::Config subclass; build(load: true, &block) calls load! and returns the loaded instance (with frozen values). The block is class_eval'd on the new subclass, so config_name, env_prefix, and attribute are available exactly as in a named class.

config_name is mandatory — anonymous classes have no name, so anyway_config can't infer it. Otherwise YAML loading and ENV nesting work identically (so config/credentials.yml, config/app_config.yml, and APP_SENTRY__DSN all behave the same as the file-based form).

Disabling ENV loading entirely

If you want a config to read only from YAML and ignore the environment (e.g. for credentials that must never leak in via stray ENV vars, or to make behavior deterministic in tests), restrict configuration_sources on the class to the YAML loader:

class Credentials < AnywayAppConfig::Config
  config_name "credentials"
  self.configuration_sources = [:yml]   # YAML only — ignore ENV, ignore Rails secrets/credentials loaders

  attribute :secret_key_base,   type: :string, required: true
  attribute :database_password, type: :string, required: true
end

configuration_sources is an array of loader IDs registered on Anyway.loaders (:yml, :env, :credentials, etc — Rails adds a few). Only listed loaders run for this class. With just [:yml], ENV variables have no effect on the values, regardless of env_prefix.

Development

bundle install
bundle exec rspec
bundle exec rubocop

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/didww/anyway_app_config.

License

MIT. See LICENSE.txt.