Environment Variables Done Right — dotenv Patterns and Production Pitfalls
ENV["STRIPE_KEY"] returns nil in production and the app boots anyway, quietly using no key, until the first payment fails four hours later. Environment variables are the simplest configuration mechanism available and the one most likely to fail silently, because a missing variable looks exactly like an empty string looks like false. A few conventions make the whole class of problem go away.
Fail Fast, Always
The single most valuable rule. Never read a required variable with [].
Example:
# Silent — nil propagates until something far away breaks
ENV["STRIPE_SECRET_KEY"]
# Loud — KeyError at the moment of the mistake
ENV.fetch("STRIPE_SECRET_KEY")
# Explicit default for genuinely optional config
ENV.fetch("REQUEST_TIMEOUT", "30")
Better still, validate everything at boot so the app refuses to start rather than failing on the first request that needs it.
Example:
# config/initializers/00_env_check.rb
REQUIRED_ENV = %w[
DATABASE_URL
SECRET_KEY_BASE
STRIPE_SECRET_KEY
REDIS_URL
].freeze
missing = REQUIRED_ENV.reject { |key| ENV[key].present? }
if missing.any?
abort <<~MSG
Missing required environment variables:
#{missing.join("\n ")}
Copy .env.example to .env and fill these in, or set them in your
deployment environment.
MSG
end
The 00_ prefix makes it run first. A deploy that fails immediately with a clear list is enormously better than one that succeeds and breaks at 2am.
Everything Is a String
The second-most-common bug. ENV values have no types.
Example:
ENV["FEATURE_ENABLED"] # => "false"
ENV["FEATURE_ENABLED"] ? :on : :off # => :on ← the string "false" is truthy
ENV["MAX_RETRIES"] + 1 # => TypeError
ENV["TIMEOUT"] > 30 # => comparison of String with 30 failed
Cast at the boundary, once:
Example:
# config/initializers/app_config.rb
module AppConfig
module_function
def boolean(key, default: false)
value = ENV[key]
return default if value.nil?
ActiveModel::Type::Boolean.new.cast(value) || false
end
def integer(key, default: nil)
value = ENV[key]
return default if value.nil? || value.empty?
Integer(value)
rescue ArgumentError
raise ArgumentError, "#{key} must be an integer, got #{value.inspect}"
end
def list(key, default: [])
ENV[key]&.split(",")&.map(&:strip)&.reject(&:empty?) || default
end
end
AppConfig.boolean("FEATURE_ENABLED") # => false, correctly
AppConfig.integer("MAX_RETRIES", default: 3) # => 3
AppConfig.list("ALLOWED_HOSTS") # => ["a.com", "b.com"]
ActiveModel::Type::Boolean treats "0", "f", "false", "off", and "" as false, which is exactly the set of things people write when they mean false.
The dotenv Setup
Example:
# Gemfile
group :development, :test do
gem "dotenv-rails"
end
Development and test only. Loading .env in production is a mistake — production configuration belongs in the deployment platform, where it’s audited and rotatable, not in a file on disk.
dotenv loads files in a specific precedence order, and knowing it prevents a lot of confusion:
.env.development.local ← highest, gitignored, personal overrides
.env.local ← gitignored, personal, all environments
.env.development ← committed, shared development defaults
.env ← committed, shared defaults
Real environment variables always win over anything in a file. That’s the property that makes FOO=bar bin/rails console work as a one-off override.
.gitignore
.env
.env.*
!.env.example
!.env.development
!.env.test
Commit the non-secret shared defaults. Never commit anything with a real credential in it.
.env.example is not optional
# .env.example
# Copy to .env and fill in. Ask #eng-infra for the shared dev credentials.
DATABASE_URL=postgres://localhost/myapp_development
REDIS_URL=redis://localhost:6379/0
# Stripe — use the test-mode key from the shared dev account
STRIPE_SECRET_KEY=sk_test_
STRIPE_WEBHOOK_SECRET=whsec_
# Optional
REQUEST_TIMEOUT=30
FEATURE_NEW_CHECKOUT=false
Keep it in sync with your REQUIRED_ENV list — a CI check that diffs the two takes five minutes to write and prevents the “new developer can’t boot the app” morning.
Credentials vs Environment Variables
Rails has two mechanisms and teams mix them incoherently. A clear division:
Rails credentials (config/credentials.yml.enc) for secrets that are the same across every instance of an environment and change rarely — third-party API keys, signing secrets. They’re encrypted, version-controlled, and reviewable in a diff.
Environment variables for anything that differs per instance or is injected by the platform — DATABASE_URL, PORT, RAILS_ENV, region, replica hostname, feature flags you want to flip without a deploy.
Rails.application.credentials.stripe[:secret_key] # stable secret
ENV.fetch("DATABASE_URL") # platform-injected
The one thing not to do is put the same value in both and let them disagree.
Pro-Tip: Read every environment variable exactly once, at boot, into a frozen config object — never call
ENV.fetchscattered through the codebase. Three reasons, and the third is the one that bites. Boot-time reads mean a missing variable fails the deploy instead of the request. A single config object is greppable, so you can answer “what does this app need?” without searching forENVacross four hundred files. AndENVis a process-global mutable hash — a gem, a test helper, or a rake task that writes to it changes behaviour underneath code that read it earlier, producing bugs that only reproduce in the full test suite and never in isolation.Config = AppConfig.load.freezein an initializer eliminates all three.
Secrets Hygiene
- Never log the whole environment.
ENV.inspectin an error handler ships every credential to your log aggregator, which is retained for ninety days and readable by everyone. - Filter parameters and env in error reporting. Sentry, Bugsnag, and friends all support a denylist. Configure it before you need it.
Rails.application.config.filter_parameters += [
:password, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
]
- Rotate anything that touched a shell history. Prefix interactive commands with a space if
HISTCONTROL=ignorespaceis set, and rotate afterwards regardless. - Different keys per environment. A staging environment holding production credentials means staging is production from a security standpoint.
Checking It in CI
Example:
#!/usr/bin/env bash
# bin/check-env-example
set -e
required=$(grep -oE '^\s+[A-Z_]+$' config/initializers/00_env_check.rb | tr -d ' ' | sort)
documented=$(grep -oE '^[A-Z_]+=' .env.example | tr -d '=' | sort)
undocumented=$(comm -23 <(echo "$required") <(echo "$documented"))
if [ -n "$undocumented" ]; then
echo "Required variables missing from .env.example:"
echo "$undocumented"
exit 1
fi
echo "✓ .env.example covers all required variables"
Also worth adding: a secret scanner (gitleaks, trufflehog) as a pre-commit hook, so a real key in a committed .env gets caught before it reaches the remote rather than after.
Conclusion
Use ENV.fetch and never ENV[], validate the full required set in a boot initializer so a bad deploy fails immediately with a readable list, and cast types at a single boundary because every value is a string and "false" is truthy. Keep dotenv to development and test, commit .env.example and check it stays in sync, and split configuration between Rails credentials for stable secrets and environment variables for per-instance values. Then read the whole lot once into a frozen object — that one change turns a mutable process global scattered across the codebase into a config you can grep, test, and trust.
FAQs
Q1: Should .env ever be committed?
No. Commit .env.example with placeholder values, and optionally .env.development if it contains only non-secret shared defaults like a local database URL.
Q2: Why does my variable work in the console but not in the server?
Different process, different environment. A variable exported in one shell isn’t visible to a server started from another, and systemd or Docker services don’t inherit your shell at all.
Q3: How do I handle multi-line values like a private key?
Base64-encode it into a single line and decode at boot. Multi-line values in .env files and in most deployment platforms are inconsistently supported and a frequent source of “works locally” failures.
Q4: Is dotenv safe to use in production?
It works, but it means secrets live in a file on the server rather than in a managed secret store, with no audit trail or rotation workflow. Use your platform’s mechanism instead.
Q5: What’s the right precedence between credentials and ENV for the same value?
Pick one source per value and document it. If you need an override mechanism, make it explicit: ENV.fetch("STRIPE_KEY") { Rails.application.credentials.stripe[:key] } — but be aware that an accidentally-set empty string in ENV will win over the credential.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀