Type-safe environment variables management for Ruby applications with a clean DSL inspired by rails-settings-cached.
- Type-safe environment variable access
- Support for multiple types: string, integer, float, boolean, array, hash, symbol
- Built-in validations (presence, length, format, inclusion)
- Clean, Rails-like DSL
- Default values
- Boolean helper methods
- Custom reader/writer callbacks for flexible storage (database, Redis, files, etc.)
- Read-only by default for security
- ActiveModel validations support (optional)
- Fully tested
Add this line to your application's Gemfile:
gem'env_settings'And then execute:
bundle installOr install it yourself as:
gem install env_settingsCreate a class that inherits from EnvSettings::Base and define your environment variables:
classEnv < EnvSettings::Basevar:app_name,type: :string,default: "MyApp"var:port,type: :integer,default: 3000var:debug,type: :boolean,default: falsevar:database_url,type: :string,validates: {presence: true}var:allowed_hosts,type: :array,default: []var:redis_config,type: :hash,default: {}endBy default, all variables are read-only and read from ENV. To enable writing or custom storage, see Custom Reader/Writer Callbacks.
# Simple accessEnv.app_name# => "MyApp"Env.port# => 3000# Boolean helperEnv.debug?# => false# Presence checkEnv.database_url_present?# => true/false# Get all settings as hashEnv.all# => { app_name: "MyApp", port: 3000, ... }Env.to_h# Same as .allImportant: By default, all variables are read-only. To enable writing, you must provide a writer callback.
# This will raise ReadOnlyErrorEnv.app_name="NewApp"# Raises EnvSettings::ReadOnlyError# To make a variable writable, provide a writer callbackclassEnv < EnvSettings::Basevar:app_name,type: :string,default: "MyApp",writer: ->(value,setting){ENV[setting[:env_key]]=value.to_s}endEnv.app_name="NewApp"# Worksvar:app_name,type: :string,default: "MyApp"# ENV["APP_NAME"] = "MyApp" => "MyApp"var:port,type: :integer,default: 3000# ENV["PORT"] = "5000" => 5000var:price,type: :float,default: 9.99# ENV["PRICE"] = "19.99" => 19.99var:debug,type: :boolean,default: false# ENV["DEBUG"] = "true" => true# ENV["DEBUG"] = "1" => true# ENV["DEBUG"] = "yes" => true# ENV["DEBUG"] = "on" => true# ENV["DEBUG"] = "false" => false# ENV["DEBUG"] = "0" => false# Boolean helper methodEnv.debug?# => true/falsevar:allowed_hosts,type: :array,default: []# JSON format# ENV["ALLOWED_HOSTS"] = '["host1", "host2"]' => ["host1", "host2"]# Comma-separated format# ENV["ALLOWED_HOSTS"] = "host1, host2, host3" => ["host1", "host2", "host3"]var:redis_config,type: :hash,default: {}# JSON format# ENV["REDIS_CONFIG"] = '{"host": "localhost", "port": 6379}'# => { "host" => "localhost", "port" => 6379 }var:log_level,type: :symbol,default: :info# ENV["LOG_LEVEL"] = "debug" => :debugvar:database_url,validates: {presence: true}var:username,validates: {length: {minimum: 3,maximum: 20}}# Or with rangevar:username,validates: {length: {in: 3..20}}var:email,validates: {format: {with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i}}# With custom messagevar:email,validates: {format: {with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i,message: "must be a valid email address"}}var:environment,validates: {inclusion: {in: %w[developmenttestproduction]}}var:api_key,validates: {presence: true,length: {minimum: 32},format: {with: /\A[a-zA-Z0-9]+\z/}}# Validate all settings at onceEnv.validate!# Will raise EnvSettings::ValidationError if any validation failsIt's recommended to run validations during application initialization:
# config/initializers/env_settings.rb (Rails)Env.validate!When you assign a value to a variable with validations, it's automatically validated before writing to storage:
classEnv < EnvSettings::Basedefault_writer->(value,setting){Setting.find_or_create_by(key: key).update!(value: value)}var:username,type: :string,validates: {presence: true,length: {minimum: 3,maximum: 20}}var:email,type: :string,validates: {format: {with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i}}end# These will raise ValidationError BEFORE writing to databaseEnv.username=""# Raises: "Username can't be blank"Env.username="ab"# Raises: "Username is too short (minimum is 3 characters)"Env.email="invalid"# Raises: "Email is invalid"# Only valid values are written to storageEnv.username="john"# Works - writes to databaseEnv.email="j@example.com"# Works - writes to databaseThis prevents invalid data from being stored and ensures data integrity at the assignment level.
EnvSettings automatically uses ActiveModel validations if activemodel gem is available. This provides:
- More validation options (numericality, comparison, exclusion, etc.)
- Better error messages with I18n support
- Custom validators
- Full compatibility with Rails
# Gemfilegem'env_settings'gem'activemodel'# Optional, but recommended for Rails projectsclassEnv < EnvSettings::Base# Numericality validationvar:port,type: :integer,default: 3000,validates: {numericality: {only_integer: true,greater_than: 0,less_than: 65536}}var:timeout,type: :float,validates: {numericality: {greater_than_or_equal_to: 0}}# Comparison validationvar:min_value,type: :integer,default: 0var:max_value,type: :integer,default: 100,validates: {comparison: {greater_than: :min_value}}# Exclusion validationvar:username,validates: {exclusion: {in: %w[adminrootsuperuser]}}# Absence validation (for deprecated variables)var:legacy_option,validates: {absence: true}# Custom validatorsvar:api_endpoint,validates: {url: true}# Uses custom UrlValidatorendEnvSettings uses ActiveModel-compatible validation syntax. This means:
- With ActiveModel (
activemodelgem installed): Full ActiveModel validations with rich features - Without ActiveModel: Built-in simple validations with the same syntax but limited to:
presence,length,format,inclusion
The syntax is identical in both cases, so your code works seamlessly:
# This syntax works with AND without activemodelvar:email,validates: {presence: true,format: {with: /regex/,message: "is not valid"}}# ActiveModel-only validators (requires activemodel gem)var:age,validates: {numericality: {greater_than: 0,less_than: 150}# Only with ActiveModel}var:password,validates: {confirmation: true# Only with ActiveModel}Recommendation: Install activemodel gem for Rails projects to get full validation features.
Create an initializer:
# config/initializers/var.rbclassEnv < EnvSettings::Basevar:app_name,type: :string,default: "MyRailsApp"var:port,type: :integer,default: 3000var:database_url,type: :string,validates: {presence: true}var:redis_url,type: :string,default: "redis://localhost:6379/0"var:smtp_host,type: :stringvar:smtp_port,type: :integer,default: 587var:enable_cache,type: :boolean,default: falsevar:allowed_hosts,type: :array,default: []var:environment,type: :string,validates: {inclusion: %w[developmentteststagingproduction]}end# Validate on startupEnv.validate!Then use throughout your application:
# config/database.ymldefault: &defaulturl: <%= Env.database_url %>
# config/environments/production.rbconfig.cache_store=:redis_cache_store,{url: Env.redis_url}# Anywhere in your codeifEnv.enable_cache?# Do somethingendEnvSettings allows you to define custom callbacks for reading and writing variables, enabling flexible storage backends like databases, Redis, or files.
classEnv < EnvSettings::Base# Read-only from ENV (default behavior)var:database_url,type: :string,validates: {presence: true}# Custom reader from databasevar:maintenance_mode,type: :boolean,default: false,reader: ->(setting){Setting.find_by(key: setting[:env_key])&.value}# Custom reader and writervar:feature_flags,type: :hash,default: {},reader: ->(setting){value=Redis.current.get("settings:#{setting[:env_key]}")value ? JSON.parse(value) : nil},writer: ->(value,setting){Redis.current.set("settings:#{setting[:env_key]}",value.to_json)}end# UsageEnv.maintenance_mode# Reads from databaseEnv.feature_flags={x: true}# Writes to RedisEnv.database_url="new"# Raises ReadOnlyError (no writer)Set default reader/writer for all variables:
classEnv < EnvSettings::Base# All variables will use these callbacks by defaultdefault_reader->(setting){Setting.find_by(key: setting[:env_key])&.value || ENV[setting[:env_key]]}default_writer->(value,setting){Setting.find_or_create_by(key: setting[:env_key]).update!(value: value)}# Now all variables are readable/writable through databasevar:api_key,type: :string,default: "default_key"var:timeout,type: :integer,default: 30# Can override for specific variablesvar:secret_key,type: :string,reader: ->(setting){ENV[setting[:env_key]]},# Only from ENVwriter: nil# Explicitly read-onlyend# Block syntax is also supportedclassEnv < EnvSettings::Basedefault_readerdo |setting|
Setting.find_by(key: setting[:env_key])&.value || ENV[setting[:env_key]]enddefault_writerdo |value,setting|
Setting.find_or_create_by(key: setting[:env_key]).update!(value: value)endendCallbacks receive the following parameters:
key- The uppercase ENV key (e.g., "API_KEY")setting- Full configuration hash with::type,:default,:validates,:env_key, etc.
Reader callback:
reader: ->(setting){# Must return raw value (string/nil)# Type coercion is applied automatically}Writer callback:
writer: ->(value,setting){# Receives the value to write# No return value expected}classEnv < EnvSettings::Basedefault_reader->(setting){Setting.find_by(key: setting[:env_key])&.value || ENV[setting[:env_key]]}default_writer->(value,setting){Setting.find_or_create_by(key: setting[:env_key]).update!(value: value)}var:maintenance_mode,type: :boolean,default: falsevar:max_connections,type: :integer,default: 10endclassEnv < EnvSettings::Basedefault_reader->(setting){Redis.current.get("app:settings:#{setting[:env_key]}")}default_writer->(value,setting){Redis.current.set("app:settings:#{setting[:env_key]}",value.to_s)}var:rate_limit,type: :integer,default: 100var:feature_x_enabled,type: :boolean,default: falseendclassEnv < EnvSettings::BaseSETTINGS_FILE="config/runtime_settings.yml"default_reader->(setting){returnENV[setting[:env_key]]unlessFile.exist?(SETTINGS_FILE)YAML.load_file(SETTINGS_FILE)[setting[:env_key]]}default_writer->(value,setting){data=File.exist?(SETTINGS_FILE) ? YAML.load_file(SETTINGS_FILE) : {}data[setting[:env_key]]=value.to_sFile.write(SETTINGS_FILE,data.to_yaml)}var:log_level,type: :symbol,default: :infoendclassEnv < EnvSettings::Basevar:api_key,type: :string,reader: ->(setting){Vault.logical.read("secret/data/#{setting[:env_key]}")&.data&.dig(:data,:value)},writer: ->(value,setting){Vault.logical.write("secret/data/#{setting[:env_key]}",data: {value: value})}endclassEnv < EnvSettings::Base# Default: read from ENV (no writer = read-only)var:database_url,type: :string,validates: {presence: true}# Runtime settings in databasevar:maintenance_mode,type: :boolean,default: false,reader: ->(setting){Setting.get(setting[:env_key])},writer: ->(value,setting){Setting.set(setting[:env_key],value)}# Feature flags in Redisvar:feature_flags,type: :hash,default: {},reader: ->(setting){JSON.parse(Redis.current.get(setting[:env_key]) || "{}")},writer: ->(value,setting){Redis.current.set(setting[:env_key],value.to_json)}# Secrets in Vaultvar:stripe_secret_key,type: :string,reader: ->(setting){Vault.read("secret/#{setting[:env_key]}")}# No writer = read-onlyendSettings that can be changed through admin panel without restart:
# ActiveRecord modelclassSetting < ApplicationRecord# Table: settings (key:string, value:text)defself.get(key)find_by(key: key)&.valueenddefself.set(key,value)find_or_create_by(key: key).update!(value: value.to_s)endend# EnvSettings configurationclassEnv < EnvSettings::Basedefault_reader->(setting){Setting.get(setting[:env_key]) || ENV[setting[:env_key]]}default_writer->(value,setting){Setting.set(setting[:env_key],value)}var:maintenance_mode,type: :boolean,default: falsevar:max_upload_size,type: :integer,default: 10_485_760var:feature_x_enabled,type: :boolean,default: falseend# Usage in admin controllerclassAdminController < ApplicationControllerdeftoggle_maintenanceEnv.maintenance_mode=params[:enabled]redirect_toadmin_path,notice: "Maintenance mode updated"endendFast feature flags with minimal latency:
classEnv < EnvSettings::Basevar:feature_flags,type: :hash,default: {},reader: ->(setting){value=Redis.current.get("flags:#{setting[:env_key]}")value ? JSON.parse(value) : nil},writer: ->(value,setting){Redis.current.set("flags:#{setting[:env_key]}",value.to_json)}end# UsageEnv.feature_flags={new_ui: true,beta_feature: false}ifEnv.feature_flags[:new_ui]render:new_designelserender:old_designendclassEnv < EnvSettings::Base@vault_cache={}var:secret_key,reader: ->(setting){@vault_cache[setting[:env_key]] ||= beginsecret=Vault.logical.read("secret/#{setting[:env_key]}")secret&.data&.dig(:data,:value)end}endclassEnv < EnvSettings::Basedefault_writer->(value,setting){old_value=Setting.get(setting[:env_key])Setting.set(setting[:env_key],value)Rails.logger.info("Setting changed: #{setting[:env_key]} = #{value.inspect} (was: #{old_value.inspect})")}end# ReadOnlyError is raised when trying to write without a writerbeginEnv.database_url="new_url"rescueEnvSettings::ReadOnlyError=>eputse.message# => "Cannot write to 'database_url': variable is read-only. Provide a writer callback to enable writing."endBefore:
DATABASE_URL=ENV.fetch('DATABASE_URL')PORT=ENV.fetch('PORT',3000).to_iDEBUG=ENV.fetch('DEBUG','false') == 'true'ALLOWED_HOSTS=ENV.fetch('ALLOWED_HOSTS','').split(',').map(&:strip)After:
classEnv < EnvSettings::Basevar:database_url,validates: {presence: true}var:port,type: :integer,default: 3000var:debug,type: :boolean,default: falsevar:allowed_hosts,type: :array,default: []endEnv.database_urlEnv.portEnv.debug?Env.allowed_hostsAfter checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests.
bundle exec rspecBug reports and pull requests are welcome on GitHub at https://github.com/ekzo-dev/env_settings.
The gem is available as open source under the terms of the MIT License.