CrackedRuby logo

CrackedRuby

dotenv

Overview

The dotenv gem loads environment variables from .env files into ENV in Ruby applications. This approach separates configuration from code, following the twelve-factor app methodology. Dotenv reads key-value pairs from plain text files and makes them available through Ruby's standard ENV hash.

The gem consists of two primary classes: Dotenv handles file loading and parsing, while Dotenv::Rails provides automatic integration for Rails applications. The core functionality centers around the Dotenv.load method, which reads .env files and populates environment variables.

require 'dotenv'
Dotenv.load

# Access loaded variables
database_url = ENV['DATABASE_URL']
api_key = ENV['API_KEY']

Environment variables loaded through dotenv take precedence over variables already present in ENV, unless explicitly configured otherwise. The gem supports multiple file formats including .env, .env.local, .env.development, and custom-named files.

# Load multiple files with precedence
Dotenv.load('.env.local', '.env')

# Load with explicit precedence control
Dotenv.overload('.env.development')

Dotenv parses files using a simple key-value syntax with support for comments, empty lines, and basic variable expansion. The parser handles quoted values and escape sequences, making it suitable for complex configuration data.

# .env file contents:
# API_KEY=abc123
# DATABASE_URL="postgres://user:pass@localhost/db"
# DEBUG=true
# # This is a comment

Dotenv.load
puts ENV['API_KEY']        # => "abc123"
puts ENV['DATABASE_URL']   # => "postgres://user:pass@localhost/db"
puts ENV['DEBUG']          # => "true"

Basic Usage

Loading environment variables requires calling Dotenv.load with file paths. The method searches for files relative to the current working directory and raises Errno::ENOENT if required files don't exist.

require 'dotenv'

# Load default .env file
Dotenv.load

# Load specific files
Dotenv.load('.env.production', '.env.local')

# Load files that may not exist
Dotenv.load('.env.optional') rescue nil

The Dotenv.load! method provides stricter error handling, raising exceptions when files are missing or contain parsing errors. Use this variant when environment files are mandatory for application operation.

# Require .env file to exist
begin
  Dotenv.load!
rescue Errno::ENOENT => e
  puts "Required environment file missing: #{e.message}"
  exit 1
end

Variable precedence follows a last-loaded-wins pattern. Variables defined in later files override those from earlier files, while existing environment variables remain unchanged unless using overload.

# .env contains: PORT=3000
# .env.local contains: PORT=4000

Dotenv.load('.env', '.env.local')
puts ENV['PORT']  # => "4000"

# Override existing ENV variables
ENV['PORT'] = '5000'
Dotenv.overload('.env.local')
puts ENV['PORT']  # => "4000"

Rails applications benefit from automatic dotenv integration. Adding gem 'dotenv-rails' to the Gemfile enables automatic loading during application initialization, with environment-specific file support.

# Gemfile
group :development, :test do
  gem 'dotenv-rails'
end

# Automatic loading order:
# .env.development.local (if Rails.env == 'development')
# .env.local (unless Rails.env == 'test')
# .env.development (if Rails.env == 'development')
# .env

Environment file syntax supports various value formats including unquoted strings, quoted strings with escape sequences, and basic variable expansion within the same file.

# .env file format examples:
BASIC=value
QUOTED="value with spaces"
MULTILINE="line1\nline2"
EXPANSION=${BASIC}_suffix

Dotenv.load
puts ENV['BASIC']      # => "value"
puts ENV['QUOTED']     # => "value with spaces"
puts ENV['MULTILINE']  # => "line1\nline2"
puts ENV['EXPANSION']  # => "value_suffix"

Error Handling & Debugging

File parsing errors occur when .env files contain malformed syntax. The parser raises Dotenv::FormatError for invalid key-value pairs, unclosed quotes, or invalid escape sequences.

# Invalid .env content:
# UNCLOSED_QUOTE="missing end quote
# INVALID_KEY =value
# =missing_key

begin
  Dotenv.load!
rescue Dotenv::FormatError => e
  puts "Parse error: #{e.message}"
  puts "Line: #{e.line}" if e.respond_to?(:line)
end

Missing file handling depends on the loading method used. Dotenv.load silently ignores missing files, while Dotenv.load! raises exceptions. Check file existence explicitly when conditional loading is required.

# Safe conditional loading
env_file = '.env.optional'
if File.exist?(env_file)
  Dotenv.load(env_file)
else
  puts "Optional environment file #{env_file} not found"
end

# Validate required files before loading
required_files = ['.env', '.env.local']
missing_files = required_files.reject { |f| File.exist?(f) }

unless missing_files.empty?
  raise "Missing required environment files: #{missing_files.join(', ')}"
end

Variable conflicts arise when multiple sources define the same key. Debug conflicts by examining the load order and using Dotenv.parse to inspect file contents without modifying ENV.

# Debug variable sources
files = ['.env', '.env.local', '.env.development']
parsed_vars = {}

files.each do |file|
  if File.exist?(file)
    vars = Dotenv.parse(file)
    vars.each do |key, value|
      parsed_vars[key] ||= []
      parsed_vars[key] << { file: file, value: value }
    end
  end
end

# Show conflicts
parsed_vars.each do |key, sources|
  if sources.length > 1
    puts "Variable #{key} defined in multiple files:"
    sources.each { |s| puts "  #{s[:file]}: #{s[:value]}" }
  end
end

Environment variable type coercion requires manual handling since all values load as strings. Implement type conversion with proper error handling for configuration parsing.

# Type-safe environment variable parsing
def parse_env_int(key, default = nil)
  value = ENV[key]
  return default if value.nil? || value.empty?
  
  Integer(value)
rescue ArgumentError
  raise "Invalid integer value for #{key}: #{value}"
end

def parse_env_bool(key, default = false)
  value = ENV[key]
  return default if value.nil? || value.empty?
  
  case value.downcase
  when 'true', '1', 'yes', 'on'
    true
  when 'false', '0', 'no', 'off'
    false
  else
    raise "Invalid boolean value for #{key}: #{value}"
  end
end

# Usage with error handling
begin
  port = parse_env_int('PORT', 3000)
  debug = parse_env_bool('DEBUG', false)
rescue => e
  puts "Environment configuration error: #{e.message}"
  exit 1
end

Production Patterns

Production deployments require careful environment file management to separate secrets from application code. Never commit .env.production or files containing sensitive credentials to version control systems.

# .gitignore entries
.env.local
.env.production
.env.*.local

# Production deployment script
#!/bin/bash
# Create production environment file
cat > .env.production << EOF
DATABASE_URL=${PRODUCTION_DATABASE_URL}
REDIS_URL=${PRODUCTION_REDIS_URL}
SECRET_KEY_BASE=${PRODUCTION_SECRET_KEY}
EOF

# Start application with production environment
RAILS_ENV=production bundle exec rails server

Container deployments benefit from combining dotenv with runtime environment variables. Use dotenv for development defaults while allowing container orchestration to override values.

# Docker-aware environment loading
class EnvironmentLoader
  def self.load_for_environment
    # Load base configuration
    Dotenv.load('.env')
    
    # Load environment-specific overrides
    env_file = ".env.#{ENV['RAILS_ENV'] || 'development'}"
    Dotenv.load(env_file) if File.exist?(env_file)
    
    # Container orchestration takes precedence
    # (ENV variables from docker run, kubernetes, etc.)
  end
end

EnvironmentLoader.load_for_environment

Configuration validation ensures required environment variables are present and valid. Implement validation early in the application lifecycle to catch misconfigurations before they cause runtime failures.

# Production configuration validator
class ConfigValidator
  REQUIRED_VARS = %w[
    DATABASE_URL
    REDIS_URL
    SECRET_KEY_BASE
    API_KEY
  ].freeze
  
  def self.validate!
    missing_vars = REQUIRED_VARS.select { |var| ENV[var].nil? || ENV[var].empty? }
    
    unless missing_vars.empty?
      raise "Missing required environment variables: #{missing_vars.join(', ')}"
    end
    
    # Validate URL formats
    validate_url('DATABASE_URL')
    validate_url('REDIS_URL')
    
    # Validate key lengths
    if ENV['SECRET_KEY_BASE'].length < 32
      raise "SECRET_KEY_BASE must be at least 32 characters"
    end
  end
  
  private
  
  def self.validate_url(key)
    require 'uri'
    URI.parse(ENV[key])
  rescue URI::InvalidURIError
    raise "Invalid URL format for #{key}: #{ENV[key]}"
  end
end

# Use in production initialization
if ENV['RAILS_ENV'] == 'production'
  Dotenv.load!('.env.production')
  ConfigValidator.validate!
end

Multi-stage deployments require environment promotion strategies. Use file naming conventions and deployment scripts to manage configuration across staging, testing, and production environments.

# Environment promotion script
class EnvironmentPromoter
  def initialize(source_env, target_env)
    @source_file = ".env.#{source_env}"
    @target_file = ".env.#{target_env}"
  end
  
  def promote
    unless File.exist?(@source_file)
      raise "Source environment file #{@source_file} not found"
    end
    
    source_vars = Dotenv.parse(@source_file)
    target_vars = File.exist?(@target_file) ? Dotenv.parse(@target_file) : {}
    
    # Merge with target taking precedence for existing keys
    merged_vars = source_vars.merge(target_vars)
    
    # Write promoted configuration
    File.open(@target_file, 'w') do |f|
      merged_vars.each { |key, value| f.puts "#{key}=#{value}" }
    end
    
    puts "Promoted #{source_vars.size} variables from #{@source_file} to #{@target_file}"
  end
end

# Promote staging to production
EnvironmentPromoter.new('staging', 'production').promote

Common Pitfalls

Variable interpolation within .env files only expands variables defined in the same file. References to existing environment variables or variables from previously loaded files don't expand automatically.

# .env file:
BASE_URL=https://api.example.com
API_ENDPOINT=${BASE_URL}/v1/users  # Works: expands to https://api.example.com/v1/users

# This doesn't work as expected:
HOME_DIR=${HOME}/app  # ${HOME} doesn't expand to system HOME variable
PATH_ADDITION=${PATH}:/custom/bin  # ${PATH} doesn't expand

Dotenv.load
puts ENV['HOME_DIR']     # => "${HOME}/app" (literal string)
puts ENV['PATH_ADDITION'] # => "${PATH}:/custom/bin" (literal string)

# Correct approach for system variables:
# Use shell expansion in deployment scripts instead
# HOME_DIR=$HOME/app

File encoding issues occur when .env files contain non-ASCII characters without proper encoding declarations. Ruby assumes UTF-8 encoding, which can cause parse errors with legacy files.

# Problematic .env with Latin-1 characters:
# APP_NAME=Configuración
# DESCRIPTION=Système de gestion

begin
  Dotenv.load('.env')
rescue => e
  puts "Encoding error: #{e.message}"
  
  # Handle encoding issues
  content = File.read('.env', encoding: 'ISO-8859-1')
  content_utf8 = content.encode('UTF-8')
  File.write('.env.utf8', content_utf8)
  Dotenv.load('.env.utf8')
end

Quote handling behaves differently than shell parsing. Double quotes interpret escape sequences while single quotes preserve literal values, but the behavior differs from POSIX shell standards.

# .env file quoting behavior:
DOUBLE_QUOTED="value with\nline break"    # Interprets \n as newline
SINGLE_QUOTED='value with\nline break'    # Treats \n as literal characters
SPACES_NO_QUOTES=value with spaces        # Only reads "value" - stops at first space
SPACES_QUOTED="value with spaces"         # Reads entire quoted string

Dotenv.load
puts ENV['DOUBLE_QUOTED']  # => "value with\nline break" (actual newline)
puts ENV['SINGLE_QUOTED']  # => "value with\\nline break" (literal \n)
puts ENV['SPACES_NO_QUOTES'] # => "value" (truncated at space)
puts ENV['SPACES_QUOTED']    # => "value with spaces"

Load order dependencies create subtle bugs when files override each other unexpectedly. Variables from the last-loaded file take precedence, which may not match intuitive expectations.

# .env contains: DEBUG=false, LOG_LEVEL=info
# .env.development contains: DEBUG=true

# Counterintuitive load order:
Dotenv.load('.env.development')  # DEBUG=true, LOG_LEVEL not set
Dotenv.load('.env')              # DEBUG=false (overrides!), LOG_LEVEL=info

puts ENV['DEBUG']     # => "false" (unexpected)
puts ENV['LOG_LEVEL'] # => "info"

# Correct load order (base files first):
Dotenv.load('.env')              # DEBUG=false, LOG_LEVEL=info  
Dotenv.load('.env.development')  # DEBUG=true (overrides correctly)

puts ENV['DEBUG']     # => "true" (expected)

Security vulnerabilities arise when environment files are exposed through web servers or included in Docker images. Ensure proper file permissions and .gitignore configurations.

# Secure environment file setup
def secure_env_files
  env_files = Dir.glob('.env*')
  
  env_files.each do |file|
    # Check file permissions (should be readable only by owner)
    stat = File.stat(file)
    mode = sprintf('%o', stat.mode & 0777)
    
    unless mode == '600'
      puts "Warning: #{file} has insecure permissions #{mode}"
      puts "Run: chmod 600 #{file}"
    end
    
    # Check if file is tracked by git
    if system("git ls-files --error-unmatch #{file} 2>/dev/null")
      puts "Error: #{file} is tracked by git - add to .gitignore"
    end
  end
end

# Validate Docker builds don't include secrets
def validate_docker_build
  dockerfile_content = File.read('Dockerfile')
  
  if dockerfile_content.include?('COPY .env')
    puts "Warning: Dockerfile copies .env files - secrets may be exposed"
  end
  
  if dockerfile_content.include?('ADD .env')
    puts "Warning: Dockerfile adds .env files - secrets may be exposed"  
  end
end

Reference

Core Methods

Method Parameters Returns Description
Dotenv.load(*filenames) filenames (String...) Hash Load variables from files, skip missing files
Dotenv.load!(*filenames) filenames (String...) Hash Load variables from files, raise if missing
Dotenv.overload(*filenames) filenames (String...) Hash Load and override existing ENV variables
Dotenv.parse(filename) filename (String) Hash Parse file without modifying ENV
Dotenv.require_keys(*keys) keys (String...) void Raise if required keys missing from ENV

File Format Syntax

Pattern Example Result
Basic key-value KEY=value ENV['KEY'] = 'value'
Quoted values KEY="quoted value" ENV['KEY'] = 'quoted value'
Single quotes KEY='literal\nvalue' ENV['KEY'] = 'literal\\nvalue'
Variable expansion KEY=${OTHER}_suffix Expands OTHER within same file
Empty values KEY= ENV['KEY'] = ''
Comments # Comment line Ignored during parsing

Rails Integration

File Load Condition Description
.env Always Base environment variables
.env.local Not in test Local overrides for all environments
.env.development In development Development-specific variables
.env.test In test Test-specific variables
.env.production In production Production-specific variables
.env.development.local In development Local development overrides

Error Classes

Exception Trigger Condition Handling Strategy
Errno::ENOENT Missing file with load! Verify file paths, check deployment
Dotenv::FormatError Invalid syntax in .env file Validate file format, escape special chars
ArgumentError Invalid parameters to methods Check method signatures, parameter types
Encoding::UndefinedConversionError Non-UTF-8 characters in file Convert file encoding or specify charset

Environment File Precedence

Load order determines variable precedence (later files override earlier ones):

# Standard Rails load order:
Dotenv.load(
  '.env',                    # Base configuration
  ".env.#{Rails.env}",       # Environment-specific  
  '.env.local',              # Local overrides (not in test)
  ".env.#{Rails.env}.local"  # Environment-specific local overrides
)

Configuration Validation Patterns

# Required variable validation
Dotenv.require_keys('DATABASE_URL', 'SECRET_KEY_BASE', 'API_KEY')

# Type conversion helpers
def env_int(key, default = nil)
  Integer(ENV[key] || default)
rescue ArgumentError, TypeError
  raise "Invalid integer for #{key}: #{ENV[key]}"
end

def env_array(key, delimiter: ',', default: [])
  (ENV[key] || '').split(delimiter).map(&:strip).reject(&:empty?)
end