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