Ruby exposes environment variables through the ENV global, a hash-like object whose keys and values are always strings. Read with ENV['KEY'] for nullable access or ENV.fetch('KEY') when a missing value should crash the process. The dotenv gem — released by Brandon Keepers in 2012 and the original of every dotenv port across languages — is the universal way to load .env files; in Rails, drop in dotenv-rails and it auto-loads on boot. Rails 5.2+ also ships credentials.yml.enc, which is the right choice for production secrets when you control deployment. This guide covers all three plus the Rails-specific bootstrap order that catches every new contributor.
How do you read an environment variable in Ruby?
ENV behaves like a frozen-keys hash whose values are always strings. Bracket access returns nil for unset keys; the fetch method raises KeyErrorunless a default is supplied — use it for required variables so failure surfaces immediately.
# Nullable access
db_url = ENV['DATABASE_URL'] # nil if unset
# With a default
port = ENV.fetch('PORT', '3000').to_i
# Required — raises KeyError if missing
api_key = ENV.fetch('API_KEY')
# Iterate
ENV.each { |k, v| puts "#{k}=#{v}" }
# Test for presence without reading the value
ENV.key?('DATABASE_URL')All values are strings, even numeric ones. .to_i coerces to integer ("returning 0 if the string isn't numeric, no exception"), .to_f to float, and %w-style splitting handles list-shaped values.
How do you set or unset variables at runtime?
Ruby lets you mutate ENV from inside the process — unlike Java, but like Node.js or Python. The mutation is visible to child processes you spawn afterwards but does not propagate back to your shell:
ENV['MY_VAR'] = 'value'
ENV.delete('MY_VAR')
ENV.update('PORT' => '4000', 'LOG_LEVEL' => 'debug')
# Spawn a subprocess that inherits the current ENV
system('echo $MY_VAR')How do you load a .env file with the dotenv gem?
Brandon Keepers' dotenv gem is the original .env loader and the one every other ecosystem ported. It does not override variables that already exist in the shell, which means real environment variables always win — the right default for "local file fills in the blanks":
# Gemfile
gem 'dotenv'
# Plain Ruby — load before reading any env vars
require 'dotenv/load'
# Or with explicit paths and override semantics
require 'dotenv'
Dotenv.load('.env.development.local', '.env.development', '.env')
# Earlier files take precedence; .env is the catch-allFor a complete reference on .env file syntax, quoting, and multi-line values, see the .env file guide.
How does dotenv-rails fit into Rails boot?
In Rails, add dotenv-rails instead of plain dotenv. It hooks into the Rails initializer chain and loads files in this order: .env.<env>.local, .env.local (skipped in test), .env.<env>, then .env. Earlier files take precedence.
# Gemfile
group :development, :test do
gem 'dotenv-rails'
endThe footgun: dotenv-rails loads after application.rb by default, so any code in config/application.rb that reads ENV sees pre-dotenv values. Use Dotenv::Railtie.load at the top of application.rb to force the early load:
# config/application.rb
require_relative 'boot'
require 'rails/all'
Bundler.require(*Rails.groups)
Dotenv::Railtie.load # ← force early load, before module Application
module MyApp
class Application < Rails::Application
# config.x.api_key now sees the .env value
config.x.api_key = ENV.fetch('API_KEY')
end
endWhen should you use Rails credentials instead?
Rails 5.2 introduced encrypted credentials (config/credentials.yml.enc + a master.key), and Rails 6 added per-environment credentials files. For production secrets that you control end-to-end and ship in the repo, this is usually the better choice than environment variables — the encrypted file is committed, and only the master.key needs to live outside version control:
bin/rails credentials:edit # default credentials
bin/rails credentials:edit --environment production# In code, never via ENV
Rails.application.credentials.dig(:aws, :access_key_id)
Rails.application.credentials.stripe!.secret_key # raises if missingUse environment variables for things that legitimately differ per deployment (database URLs, log levels) and credentials for secrets bundled with the application.
Practical patterns and gotchas
- Always strings.
ENV['DEBUG'] = truestores the literal string"true". Boolean parsing in Ruby has the same trap as Python:!!"false"istruebecause the string is non-empty. - Use ENV.fetch for required variables. The KeyError it raises tells you exactly which variable is missing, with a stack trace that points to the call site — much better than a downstream
NoMethodError on nil:NilClass. - .env.local for personal overrides, committed
.envwith placeholders for shared defaults. The standard.gitignorerule is to ignore.envand.env.*except.env.example. - Don't commit dotenv-rails to production. Put it in the
development, testgroup of yourGemfile; production should source variables from the platform (Heroku config, Kubernetes Secrets, AWS Parameter Store). - Generate or validate your .env files with the .env builder and the .env validator.
For language-agnostic security and validation rules, see environment variable best practices.