Environment Variables

Master environment variable management with ENV, dotenv gem, and secure configuration practices.

Beginner 1 min read

Environment Variables

Master Ruby's environment variable handling with the ENV hash. Learn to access system environment variables, manage configuration securely, and implement environment-specific behavior for development, testing, and production deployments.

What are Environment Variables?

Environment Variables: Key-value pairs that exist in the operating system environment. They provide a way to configure applications without changing code, store sensitive information securely, and adapt behavior based on deployment environment.

Why Use Environment Variables?

  • Security: Keep secrets out of source code
  • Flexibility: Different configurations per environment
  • Deployment: Easy configuration changes without code updates
  • Twelve-Factor Apps: Follow modern application principles

Common Environment Variables

PATH
System executable directories
HOME
User's home directory
USER
Current username
RAILS_ENV
Rails environment (development/production)
DATABASE_URL
Database connection string
API_KEY
Third-party service credentials

Accessing Environment Variables

Basic ENV Access

# Access environment variables
puts ENV["HOME"]           # User's home directory
puts ENV["PATH"]           # System PATH
puts ENV["USER"]           # Current username

# Check if variable exists
puts ENV["DATABASE_URL"]   # nil if not set
puts ENV.key?("NODE_ENV")  # true/false

# Get with default value
database_url = ENV["DATABASE_URL"] || "sqlite3://localhost/development.db"
port = ENV["PORT"] || "3000"

# More elegant default handling
def env_var(name, default = nil)
  ENV[name] || default
end

host = env_var("HOST", "localhost")
debug_mode = env_var("DEBUG", "false") == "true"

# Fetch with error handling
begin
  api_key = ENV.fetch("API_KEY")
rescue KeyError
  puts "API_KEY environment variable is required"
  exit 1
end

# Fetch with default
redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379")

# Environment-specific behavior
case ENV["RAILS_ENV"]
when "development"
  puts "Running in development mode"
when "production"
  puts "Running in production mode"
when "test"
  puts "Running in test mode"
else
  puts "Unknown environment"
end

Iterating Through Environment Variables

# Print all environment variables
ENV.each do |key, value|
  puts "#{key}: #{value}"
end

# Find variables matching a pattern
app_vars = ENV.select { |key, value| key.start_with?("APP_") }
app_vars.each { |key, value| puts "#{key}: #{value}" }

# Get all variable names
puts "Environment variables:"
ENV.keys.sort.each { |key| puts "  #{key}" }

# Filter sensitive information
safe_vars = ENV.reject { |key, value| key.include?("SECRET") || key.include?("PASSWORD") }
safe_vars.each { |key, value| puts "#{key}: #{value}" }

# Convert to hash for manipulation
env_hash = ENV.to_h
database_vars = env_hash.select { |key, value| key.start_with?("DATABASE_") }

# Check for required variables
required_vars = ["DATABASE_URL", "SECRET_KEY_BASE", "RAILS_ENV"]
missing_vars = required_vars.reject { |var| ENV.key?(var) }

if missing_vars.any?
  puts "Missing required environment variables: #{missing_vars.join(', ')}"
  exit 1
end

Environment Variable Types

Environment variables are always strings. Convert them appropriately:

# String to integer
port = ENV["PORT"]&.to_i || 3000

# String to boolean
debug = ENV["DEBUG"] == "true"
verbose = %w[true yes 1 on].include?(ENV["VERBOSE"]&.downcase)

# String to array
allowed_hosts = ENV["ALLOWED_HOSTS"]&.split(",") || ["localhost"]

# String to float
timeout = ENV["TIMEOUT"]&.to_f || 30.0

Setting Environment Variables

Setting Variables in Ruby

# Set environment variables at runtime
ENV["MY_APP_CONFIG"] = "production"
ENV["DEBUG_MODE"] = "true"

# Set multiple variables
ENV.update({
  "APP_NAME" => "MyRubyApp",
  "APP_VERSION" => "1.0.0",
  "LOG_LEVEL" => "info"
})

# Delete environment variables
ENV.delete("TEMP_VAR")

# Set variables conditionally
ENV["DEFAULT_TIMEOUT"] ||= "30"  # Only set if not already present

# Temporary environment changes
original_env = ENV["RAILS_ENV"]
ENV["RAILS_ENV"] = "test"

# ... do something in test environment ...

ENV["RAILS_ENV"] = original_env  # Restore

# Using a block for temporary changes
def with_env(changes)
  original = changes.map { |key, _| [key, ENV[key]] }.to_h

  begin
    ENV.update(changes)
    yield
  ensure
    original.each { |key, value| value ? ENV[key] = value : ENV.delete(key) }
  end
end

# Usage
with_env("RAILS_ENV" => "test", "DEBUG" => "true") do
  # Code runs with temporary environment
  puts ENV["RAILS_ENV"]  # "test"
end
# Environment is restored automatically

Shell Commands and Scripts

# Unix/Linux/macOS shell commands

# Set for current session
export DATABASE_URL="postgresql://localhost/myapp"
export RAILS_ENV="development"
export DEBUG="true"

# Set for single command
DATABASE_URL="postgresql://localhost/test" rails console

# Set multiple variables for single command
RAILS_ENV="test" DEBUG="true" bundle exec rspec

# Add to shell profile (~/.bashrc, ~/.zshrc)
echo 'export API_KEY="your-secret-key"' >> ~/.bashrc

# Load environment from file
source .env  # or
. .env

# Windows Command Prompt
set DATABASE_URL=postgresql://localhost/myapp
set RAILS_ENV=development

# Windows PowerShell
$env:DATABASE_URL = "postgresql://localhost/myapp"
$env:RAILS_ENV = "development"

Environment Variable Persistence

  • Shell Session: Variables last until session ends
  • Shell Profile: Add to ~/.bashrc or ~/.zshrc for persistence
  • .env Files: Use with dotenv gem for project-specific variables
  • System-wide: Add to /etc/environment (Linux) or system settings
  • Docker/Containers: Set in Dockerfile or docker-compose.yml

.env Files and dotenv Gem

Using dotenv Gem

# Add to Gemfile
gem 'dotenv-rails', groups: [:development, :test]

# Install
bundle install

# Create .env file in project root
# .env
DATABASE_URL=postgresql://localhost/myapp_development
REDIS_URL=redis://localhost:6379/0
SECRET_KEY_BASE=your-secret-key-here
API_KEY=your-api-key
DEBUG=true
LOG_LEVEL=debug
ALLOWED_HOSTS=localhost,127.0.0.1
SMTP_HOST=smtp.example.com
SMTP_PORT=587

# Load in Rails application
# config/application.rb (Rails automatically loads .env files with dotenv-rails)

# Load manually in non-Rails apps
require 'dotenv/load'

# Or load explicitly
require 'dotenv'
Dotenv.load('.env')

# Load environment-specific files
Dotenv.load('.env.local', '.env')  # .env.local overrides .env

# Verify required variables
Dotenv.require_keys("DATABASE_URL", "SECRET_KEY_BASE")

# Access variables normally
puts ENV["DATABASE_URL"]
puts ENV["API_KEY"]

Environment-Specific .env Files

# File structure
.env                 # Default environment variables
.env.local          # Local overrides (gitignored)
.env.development    # Development-specific
.env.test           # Test-specific
.env.production     # Production-specific

# .env (shared defaults)
APP_NAME=MyRubyApp
LOG_LEVEL=info
TIMEOUT=30

# .env.development
DATABASE_URL=postgresql://localhost/myapp_development
REDIS_URL=redis://localhost:6379/0
DEBUG=true
LOG_LEVEL=debug

# .env.test
DATABASE_URL=postgresql://localhost/myapp_test
REDIS_URL=redis://localhost:6379/1
DEBUG=false
LOG_LEVEL=warn

# .env.production
DATABASE_URL=postgresql://prod-host/myapp_production
REDIS_URL=redis://prod-redis:6379/0
DEBUG=false
LOG_LEVEL=error

# Custom loading logic
require 'dotenv'

class EnvironmentLoader
  def self.load
    env = ENV["RAILS_ENV"] || "development"

    files = [
      ".env.#{env}.local",
      ".env.local",
      ".env.#{env}",
      ".env"
    ].select { |file| File.exist?(file) }

    Dotenv.load(*files)
  end
end

EnvironmentLoader.load

Security Best Practices

  • Never commit secrets: Add .env* to .gitignore
  • Use .env.example: Template file without actual secrets
  • Rotate secrets regularly: Change API keys and passwords
  • Principle of least privilege: Only grant necessary permissions
  • Environment isolation: Different secrets per environment

Configuration Management Patterns

Configuration Class Pattern

class AppConfig
  class << self
    def database_url
      ENV.fetch("DATABASE_URL") { "sqlite3://localhost/development.db" }
    end

    def redis_url
      ENV.fetch("REDIS_URL") { "redis://localhost:6379/0" }
    end

    def debug_mode?
      %w[true yes 1 on].include?(ENV["DEBUG"]&.downcase)
    end

    def port
      ENV.fetch("PORT") { "3000" }.to_i
    end

    def timeout
      ENV.fetch("TIMEOUT") { "30" }.to_f
    end

    def allowed_hosts
      ENV.fetch("ALLOWED_HOSTS") { "localhost" }.split(",").map(&:strip)
    end

    def api_key
      ENV.fetch("API_KEY") do
        raise "API_KEY environment variable is required"
      end
    end

    def log_level
      level = ENV.fetch("LOG_LEVEL") { "info" }.downcase
      %w[debug info warn error fatal].include?(level) ? level : "info"
    end

    def environment
      ENV.fetch("RAILS_ENV") { ENV.fetch("RACK_ENV") { "development" } }
    end

    def production?
      environment == "production"
    end

    def development?
      environment == "development"
    end

    def test?
      environment == "test"
    end
  end
end

# Usage
puts "Starting app on port #{AppConfig.port}"
puts "Debug mode: #{AppConfig.debug_mode?}"
puts "Environment: #{AppConfig.environment}"

Structured Configuration

require 'ostruct'

class Config
  def self.load
    OpenStruct.new(
      app: OpenStruct.new(
        name: ENV.fetch("APP_NAME") { "MyRubyApp" },
        version: ENV.fetch("APP_VERSION") { "1.0.0" },
        host: ENV.fetch("HOST") { "localhost" },
        port: ENV.fetch("PORT") { "3000" }.to_i,
        timeout: ENV.fetch("TIMEOUT") { "30" }.to_f
      ),

      database: OpenStruct.new(
        url: ENV.fetch("DATABASE_URL"),
        pool_size: ENV.fetch("DB_POOL_SIZE") { "5" }.to_i,
        timeout: ENV.fetch("DB_TIMEOUT") { "5000" }.to_i
      ),

      redis: OpenStruct.new(
        url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/0" },
        timeout: ENV.fetch("REDIS_TIMEOUT") { "1" }.to_f
      ),

      logging: OpenStruct.new(
        level: ENV.fetch("LOG_LEVEL") { "info" },
        file: ENV["LOG_FILE"],  # Optional
        syslog: ENV["SYSLOG"] == "true"
      ),

      features: OpenStruct.new(
        debug: ENV["DEBUG"] == "true",
        analytics: ENV.fetch("ANALYTICS") { "true" } == "true",
        notifications: ENV.fetch("NOTIFICATIONS") { "true" } == "true"
      ),

      external: OpenStruct.new(
        api_key: ENV.fetch("API_KEY"),
        api_url: ENV.fetch("API_URL") { "https://api.example.com" },
        webhook_secret: ENV["WEBHOOK_SECRET"]
      )
    )
  end
end

# Initialize configuration
CONFIG = Config.load

# Usage
puts "App: #{CONFIG.app.name} v#{CONFIG.app.version}"
puts "Running on #{CONFIG.app.host}:#{CONFIG.app.port}"
puts "Database: #{CONFIG.database.url}"
puts "Debug mode: #{CONFIG.features.debug}"

Practical Examples

Database Connection Manager

require 'uri'

class DatabaseManager
  def self.connection_params
    url = ENV.fetch("DATABASE_URL") do
      raise "DATABASE_URL environment variable is required"
    end

    uri = URI.parse(url)

    {
      adapter: adapter_from_scheme(uri.scheme),
      host: uri.host,
      port: uri.port,
      database: uri.path[1..-1],  # Remove leading slash
      username: uri.user,
      password: uri.password,
      pool: ENV.fetch("DB_POOL_SIZE") { "5" }.to_i,
      timeout: ENV.fetch("DB_TIMEOUT") { "5000" }.to_i,
      encoding: ENV.fetch("DB_ENCODING") { "utf8" },
      ssl: ENV["DB_SSL"] == "true"
    }
  end

  def self.adapter_from_scheme(scheme)
    case scheme
    when "postgresql", "postgres"
      "postgresql"
    when "mysql", "mysql2"
      "mysql2"
    when "sqlite3", "sqlite"
      "sqlite3"
    else
      raise "Unsupported database scheme: #{scheme}"
    end
  end

  def self.test_connection
    params = connection_params
    puts "Testing connection to #{params[:adapter]} database..."
    puts "Host: #{params[:host]}:#{params[:port]}"
    puts "Database: #{params[:database]}"
    puts "Username: #{params[:username]}"
    # Test actual connection here
  end
end

# Usage
begin
  DatabaseManager.test_connection
rescue => e
  puts "Database connection failed: #{e.message}"
  exit 1
end

Multi-Service Configuration

class ServiceConfig
  SERVICES = %w[database redis email storage monitoring].freeze

  def self.validate!
    errors = []

    # Check required environment variables
    required_vars = [
      "DATABASE_URL",
      "SECRET_KEY_BASE",
      "RAILS_ENV"
    ]

    missing = required_vars.reject { |var| ENV.key?(var) }
    errors << "Missing required variables: #{missing.join(', ')}" if missing.any?

    # Validate email service if enabled
    if email_enabled?
      email_vars = ["SMTP_HOST", "SMTP_PORT", "SMTP_USERNAME", "SMTP_PASSWORD"]
      missing_email = email_vars.reject { |var| ENV.key?(var) }
      errors << "Email enabled but missing: #{missing_email.join(', ')}" if missing_email.any?
    end

    # Validate storage service
    if storage_service == "s3"
      s3_vars = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_BUCKET"]
      missing_s3 = s3_vars.reject { |var| ENV.key?(var) }
      errors << "S3 storage selected but missing: #{missing_s3.join(', ')}" if missing_s3.any?
    end

    if errors.any?
      puts "Configuration errors:"
      errors.each { |error| puts "  - #{error}" }
      exit 1
    end
  end

  def self.email_enabled?
    ENV.fetch("EMAIL_ENABLED") { "false" } == "true"
  end

  def self.storage_service
    ENV.fetch("STORAGE_SERVICE") { "local" }
  end

  def self.monitoring_enabled?
    ENV.fetch("MONITORING_ENABLED") { "false" } == "true"
  end

  def self.print_summary
    puts "Service Configuration Summary:"
    puts "  Environment: #{ENV['RAILS_ENV']}"
    puts "  Email: #{email_enabled? ? 'Enabled' : 'Disabled'}"
    puts "  Storage: #{storage_service.upcase}"
    puts "  Monitoring: #{monitoring_enabled? ? 'Enabled' : 'Disabled'}"
    puts "  Debug mode: #{ENV['DEBUG'] == 'true' ? 'On' : 'Off'}"
  end
end

# Validate configuration on startup
ServiceConfig.validate!
ServiceConfig.print_summary