Skip to content

Repository files navigation

EnvSettings

Type-safe environment variables management for Ruby applications with a clean DSL inspired by rails-settings-cached.

Features

  • 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

Installation

Add this line to your application's Gemfile:

gem'env_settings'

And then execute:

bundle install

Or install it yourself as:

gem install env_settings

Usage

Basic Setup

Create 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: {}end

By default, all variables are read-only and read from ENV. To enable writing or custom storage, see Custom Reader/Writer Callbacks.

Reading Values

# 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 .all

Setting Values

Important: 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"# Works

Supported Types

String (default)

var:app_name,type: :string,default: "MyApp"# ENV["APP_NAME"] = "MyApp" => "MyApp"

Integer

var:port,type: :integer,default: 3000# ENV["PORT"] = "5000" => 5000

Float

var:price,type: :float,default: 9.99# ENV["PRICE"] = "19.99" => 19.99

Boolean

var: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/false

Array

var: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"]

Hash

var:redis_config,type: :hash,default: {}# JSON format# ENV["REDIS_CONFIG"] = '{"host": "localhost", "port": 6379}'# => { "host" => "localhost", "port" => 6379 }

Symbol

var:log_level,type: :symbol,default: :info# ENV["LOG_LEVEL"] = "debug" => :debug

Validations

Presence

var:database_url,validates: {presence: true}

Length

var:username,validates: {length: {minimum: 3,maximum: 20}}# Or with rangevar:username,validates: {length: {in: 3..20}}

Format (Regex)

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"}}

Inclusion

var:environment,validates: {inclusion: {in: %w[developmenttestproduction]}}

Multiple Validations

var:api_key,validates: {presence: true,length: {minimum: 32},format: {with: /\A[a-zA-Z0-9]+\z/}}

Running Validations

# Validate all settings at onceEnv.validate!# Will raise EnvSettings::ValidationError if any validation fails

It's recommended to run validations during application initialization:

# config/initializers/env_settings.rb (Rails)Env.validate!

Automatic Validation on Write

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 database

This prevents invalid data from being stored and ensures data integrity at the assignment level.

ActiveModel Validations (Optional)

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

Installation with ActiveModel

# Gemfilegem'env_settings'gem'activemodel'# Optional, but recommended for Rails projects

ActiveModel Validation Examples

classEnv < 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 UrlValidatorend

Validation Syntax

EnvSettings uses ActiveModel-compatible validation syntax. This means:

  • With ActiveModel (activemodel gem 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.

Rails Integration

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 somethingend

Custom Reader/Writer Callbacks

EnvSettings allows you to define custom callbacks for reading and writing variables, enabling flexible storage backends like databases, Redis, or files.

Individual Variable Callbacks

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)

Global Default Callbacks

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)endend

Callback Parameters

Callbacks 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}

Practical Examples

ActiveRecord Storage

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: 10end

Redis Storage

classEnv < 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: falseend

YAML File Storage

classEnv < 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: :infoend

Vault/Secrets Manager

classEnv < 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})}end

Mixed Strategy

classEnv < 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-onlyend

Additional Use Cases

Runtime Configuration with Database

Settings 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"endend

Feature Flags with Redis

Fast 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_designend

Caching Expensive Operations

classEnv < 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}end

Logging Changes

classEnv < 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

Error Handling

# 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."end

Example: Replacing ENV.fetch

Before:

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_hosts

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests.

Testing

bundle exec rspec

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/ekzo-dev/env_settings.

License

The gem is available as open source under the terms of the MIT License.

About

EnvSettings provides a clean DSL for managing environment variables with type coercion, validations, and default values

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages