/ Tags: DEVELOPER TIPS / Categories: TIPS

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.fetch scattered 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 for ENV across four hundred files. And ENV is 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.freeze in an initializer eliminates all three.

Secrets Hygiene


  • Never log the whole environment. ENV.inspect in 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=ignorespace is 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.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More