Isometric illustration of configuration switches and a key feeding settings into two groups of containers

To manage environment variables in Rails well, keep deploy-specific settings (database URLs, API hosts, thread counts, feature flags) in ENV, read them through one typed configuration object with ENV.fetch, validate the required ones when the app boots, and set them through your platform’s secret store, never in git. Use dotenv only for local development. Most configuration bugs in production are a missing variable that nobody noticed until a code path used it, or a "false" string that Ruby treats as true. This How-To shows a setup that catches both, with the commands for Heroku, Render, Fly.io, Upsun and Kamal.


TL;DR

  • Use ENV for values that differ between deploy targets or are injected by the platform (DATABASE_URL, REDIS_URL, RAILS_MAX_THREADS). Use encrypted credentials for secrets you want versioned in git.
  • Read variables with ENV.fetch("NAME") so a missing one raises immediately, and parse booleans and integers explicitly. ENV values are always strings, and "false" is truthy.
  • Put all reads in one AppConfig module and validate the required variables at boot, skipping validation when SECRET_KEY_BASE_DUMMY is set during asset builds.
  • Use dotenv for development and test only. Commit .env.example and never commit .env.
  • Changing a variable restarts the app on Heroku and Fly.io. Build-time and runtime variables are different things, so never pass secrets as Docker build args.

Table of Contents

Should I use environment variables or Rails credentials?

Use environment variables for anything the platform injects or that changes between deploy targets, and Rails encrypted credentials for secrets you want versioned, reviewed and encrypted in the repository. Many apps use both, and that’s fine as long as each setting has exactly one home.

ENV, credentials or code: where each kind of Rails setting belongs

  Environment variables Encrypted credentials Code / config.x
Where it lives Platform, shell, secret manager config/credentials/*.yml.enc in git Ruby files in git
Change without a deploy Yes (restart) No No
Versioned and reviewable No Yes, encrypted Yes
Good for URLs injected by add-ons, thread counts, hosts, feature flags API keys, secret_key_base, signing keys Constants that are the same everywhere
Main risk Missing or mistyped in one environment Lost key, key shared too widely Secrets committed in plain text

The rest of this guide is about ENV. Credentials get their own guide later this month.

Pro Tip: Rails 8.2, still in development at the time of writing, adds Rails.app.creds, which looks a key up in ENV first and falls back to encrypted credentials. The AppConfig pattern below gives you the same “one place to read config” today.

Prerequisites

  • Rails 7.1+ (examples use Rails 8.0/8.1 and Ruby 3.3).
  • The dotenv gem (3.x) for development and test.
  • Access to your platform’s dashboard or CLI, or a Kamal setup with .kamal/secrets.

Step 1: Load variables locally with dotenv

dotenv loads variables from .env files into ENV when Rails boots in development and test. Since version 3, the dotenv gem includes the Rails integration, so dotenv-rails isn’t needed.

# Gemfile
group :development, :test do
  gem "dotenv"
end

Files are loaded in this order, and the first one that sets a variable wins:

File Commit to git? Purpose
.env.development.local / .env.test.local No Your personal overrides per environment
.env.local No Your personal overrides (not loaded in test)
.env.development / .env.test Yes, if no secrets Shared defaults per environment
.env No Shared local defaults

Commit an example file that documents every variable, with safe placeholder values:

# .env.example — copy to .env and fill in
APP_HOST=localhost:3000
DATABASE_URL=postgres://postgres:postgres@localhost:5432/myapp_development
REDIS_URL=redis://localhost:6379/0
STRIPE_SECRET_KEY=sk_test_replace_me
SMTP_PASSWORD=
FEATURE_NEW_CHECKOUT=false
RAILS_MAX_THREADS=3
# .gitignore
/.env
/.env.*
!/.env.example

Variables that are already set in the real environment take precedence over .env files, so a shell export or a CI variable always wins over the file.

Step 2: Read variables through one typed config object

Read every variable in one place, convert it to the right type, and use that object everywhere else. Scattered ENV["X"] calls hide what the app depends on, and they return strings even when you meant a number or a boolean.

# config/initializers/00_app_config.rb
module AppConfig
  BOOLEAN = ActiveModel::Type::Boolean.new

  module_function

  # Required string: raises KeyError if missing.
  def fetch(name) = ENV.fetch(name)

  def string(name, default:) = ENV.fetch(name, default)
  def integer(name, default:) = Integer(ENV.fetch(name, default.to_s))
  def boolean(name, default:) = BOOLEAN.cast(ENV.fetch(name, default.to_s))

  # Typed accessors used by the app.
  def app_host = string("APP_HOST", default: "localhost:3000")
  def stripe_secret_key = fetch("STRIPE_SECRET_KEY")
  def smtp_password = fetch("SMTP_PASSWORD")
  def new_checkout? = boolean("FEATURE_NEW_CHECKOUT", default: false)
  def max_threads = integer("RAILS_MAX_THREADS", default: 3)
end

The file name starts with 00_ so it loads before other initializers that may use it. Then use it in configuration and code:

# config/environments/production.rb
Rails.application.configure do
  config.action_mailer.default_url_options = { host: AppConfig.app_host, protocol: "https" }
  config.hosts << AppConfig.app_host
end
# config/initializers/stripe.rb
Stripe.api_key = AppConfig.stripe_secret_key unless ENV["SECRET_KEY_BASE_DUMMY"]
<%# app/views/carts/show.html.erb %>
<% if AppConfig.new_checkout? %>
  <%= render "checkouts/new_flow" %>
<% end %>

Why the explicit types matter:

ENV["FEATURE_NEW_CHECKOUT"] = "false"
!!ENV["FEATURE_NEW_CHECKOUT"]                        # => true  (any non-nil string is truthy)
AppConfig.boolean("FEATURE_NEW_CHECKOUT", default: false) # => false

ActiveModel::Type::Boolean treats "false", "0", "off", "f" and "" as false, the same rules Rails uses for form checkboxes. Integer(...) raises on "three" instead of quietly returning 0 like "three".to_i.

Step 3: Validate required variables at boot

Check every required variable when the app boots in production, and fail with one clear message listing all missing names. A deploy that fails health checks because of a missing variable is far better than a checkout page that fails three days later.

# config/initializers/00_app_config.rb (continued)
module AppConfig
  REQUIRED_IN_PRODUCTION = %w[
    DATABASE_URL
    APP_HOST
    STRIPE_SECRET_KEY
    SMTP_PASSWORD
  ].freeze

  def self.validate!
    missing = REQUIRED_IN_PRODUCTION.select { |name| ENV[name].blank? }
    return if missing.empty?

    raise KeyError, "missing required config: #{missing.join(', ')}"
  end
end

# Skip during `assets:precompile`, which boots the app without real secrets.
AppConfig.validate! if Rails.env.production? && ENV["SECRET_KEY_BASE_DUMMY"].blank?

Because the check runs during boot, a release with a missing variable never passes the /up health check, and the old release keeps serving. That interaction is explained in the Rails health check guide. The SECRET_KEY_BASE_DUMMY guard keeps assets:precompile working in the Docker build, which deliberately runs without secrets, as covered in the assets precompile guide.

Step 4: Set variables in production

Set production variables through the platform’s own secret store, from the CLI or dashboard, never from a file committed to the repository.

Heroku:

heroku config:set STRIPE_SECRET_KEY=sk_live_xxx SMTP_PASSWORD=xxx APP_HOST=example.com -a myapp
heroku config -a myapp

Render: set variables on the service, or in an Environment Group shared by web and worker services. In render.yaml, reference the group and keep secret values out of the file:

# render.yaml
envVarGroups:
  - name: myapp-shared
    envVars:
      - key: APP_HOST
        value: example.com
      - key: STRIPE_SECRET_KEY
        sync: false        # set the value in the dashboard, never in git

services:
  - type: web
    name: myapp-web
    runtime: ruby
    buildCommand: bundle install && bin/rails assets:precompile
    startCommand: bundle exec puma -C config/puma.rb
    envVars:
      - fromGroup: myapp-shared
      - key: DATABASE_URL
        fromDatabase:
          name: myapp-db
          property: connectionString

Fly.io: non-secret values go in fly.toml, secrets go through fly secrets:

# fly.toml
[env]
  APP_HOST = "example.com"
  RAILS_MAX_THREADS = "3"
fly secrets set STRIPE_SECRET_KEY=sk_live_xxx SMTP_PASSWORD=xxx
fly secrets list   # shows names and digests, never values

Upsun: variables prefixed with env: are exposed as environment variables. Mark secrets as sensitive and hide them from the build:

upsun variable:create --level environment --environment main \
  --name env:STRIPE_SECRET_KEY --value sk_live_xxx \
  --sensitive true --visible-build false --visible-runtime true

Kamal 2: declare variable names in config/deploy.yml and resolve the values locally in .kamal/secrets, optionally from a password manager:

# config/deploy.yml
env:
  clear:
    APP_HOST: example.com
    RAILS_MAX_THREADS: 3
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_URL
    - STRIPE_SECRET_KEY
    - SMTP_PASSWORD
# .kamal/secrets (committed: contains only commands and references, no values)
SECRETS=$(kamal secrets fetch --adapter 1password --account my-team --from Production/myapp RAILS_MASTER_KEY STRIPE_SECRET_KEY SMTP_PASSWORD DATABASE_URL)

RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS})
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY ${SECRETS})
SMTP_PASSWORD=$(kamal secrets extract SMTP_PASSWORD ${SECRETS})
DATABASE_URL=$(kamal secrets extract DATABASE_URL ${SECRETS})

Kamal also ships adapters for Bitwarden, LastPass, AWS Secrets Manager, GCP Secret Manager and Doppler. The full Kamal deployment is in minimal Rails deployments with Kamal 2 and Thruster, and per-environment destinations are in the staging environment guide.

Step 5: Keep build-time and runtime variables apart

Runtime variables are available when the app runs. Build-time variables are available while the image or slug is built, and they can end up stored in it. Secrets belong only at runtime.

  Build time Runtime
Used for bundle install from private gem servers, assets:precompile Everything the running app reads
Rails needs secrets? No, with SECRET_KEY_BASE_DUMMY=1 Yes
Docker ARG / --build-arg, visible in docker history -e / env at docker run
Safe for secrets Only with BuildKit secret mounts Yes

If a build genuinely needs a secret, such as a token for a private gem server, use a BuildKit secret mount so it’s never written to a layer:

# Dockerfile (build stage)
RUN --mount=type=secret,id=BUNDLE_GITHUB__COM \
    BUNDLE_GITHUB__COM="$(cat /run/secrets/BUNDLE_GITHUB__COM)" bundle install
docker build --secret id=BUNDLE_GITHUB__COM,env=BUNDLE_GITHUB__COM -t myapp .

Render turns service environment variables into Docker build arguments automatically, so on Render, don’t reference secret variables in ARG lines. Use secret files instead.

Step 6: Verify it works

Confirm that the running app sees the variables you expect, with the right types, and that a missing one stops the boot.

# Locally: missing variable fails fast in production mode
# (dotenv doesn't load in production mode, so set the others explicitly)
env -u STRIPE_SECRET_KEY RAILS_ENV=production DATABASE_URL=postgres://localhost/myapp_development \
  APP_HOST=localhost SMTP_PASSWORD=x bin/rails runner 'puts :booted'
# KeyError: missing required config: STRIPE_SECRET_KEY

# Types are parsed, not strings
FEATURE_NEW_CHECKOUT=false bin/rails runner 'p AppConfig.new_checkout?'
# false

In production, check names without printing values:

kamal app exec --reuse 'bin/rails runner "puts AppConfig::REQUIRED_IN_PRODUCTION.map { |k| [k, ENV[k].present?] }.to_h"'
# {"DATABASE_URL"=>true, "APP_HOST"=>true, "STRIPE_SECRET_KEY"=>true, "SMTP_PASSWORD"=>true}

Never print secret values in a shared terminal or CI log. Check for presence, length or a prefix (sk_live_), not the value itself.

How Heroku, Render, Fly.io, Upsun and Enkihost manage environment variables

Heroku, Render, Fly.io and Upsun store variables encrypted, inject them at runtime and restart or redeploy the app when they change. On Enkihost, a change applies on the next deploy. They differ in grouping, build-time visibility and how changes are applied.

Platform Where set Shared across services Visible at build Effect of a change
Heroku Config vars (heroku config:set) Per app; add-ons inject their own (DATABASE_URL, REDIS_URL) Yes, during slug compilation New release, dynos restart
Render Service env vars, Environment Groups Yes, via groups Yes (Docker: as build args) Redeploy of the service
Fly.io fly secrets and [env] in fly.toml Per app Secrets: no (use build secrets) Machines restart (--stage to defer)
Upsun upsun variable:create, env: prefix Project and environment levels, inherited by child environments Configurable (--visible-build) Redeploy of the environment
Kamal 2 env in deploy.yml + .kamal/secrets Per destination Only if passed as build args Next kamal deploy or kamal env push
Enkihost Dashboard environment variables Per app; DATABASE_URL and REDIS_URL injected Not documented Applied on the next deploy

Upsun’s inheritance is the detail to watch: variables set at the project level are visible to every environment, including preview branches, unless overridden. Keep live payment keys at the environment level on main only.

Enkihost’s included PostgreSQL and Redis inject DATABASE_URL and REDIS_URL, the same names Heroku and most other platforms use, so database.yml and the AppConfig pattern above work unchanged.

Troubleshooting environment variables in Rails

KeyError: key not found: "STRIPE_SECRET_KEY"

KeyError: key not found: "STRIPE_SECRET_KEY"
  config/initializers/stripe.rb:1:in `<main>'

ENV.fetch is doing its job: the variable isn’t set where the process runs. Check the right process type. Workers need the same variables as web. Check the right environment (staging vs production), and remember that Kamal reads .kamal/secrets on the machine you deploy from.

A feature flag set to false is still on

ENV["FLAG"] returns the string "false", which is truthy in Ruby. Parse it with ActiveModel::Type::Boolean.new.cast, as in AppConfig.boolean.

The variable changed but the app still uses the old value

Environment variables are read when the process starts. On Kamal, run kamal deploy (or kamal env push followed by kamal app boot). On Fly.io, fly secrets set --stage doesn’t restart machines until the next deploy. Values memoized in a constant at boot also need a restart.

Variable works at runtime but not during assets:precompile

The build runs in a different environment from the running app. Make initializers tolerant of missing secrets during the build (unless ENV["SECRET_KEY_BASE_DUMMY"]), or provide non-secret build-time values explicitly. Never copy runtime secrets into build arguments.

Values have quotes or trailing spaces

STRIPE_SECRET_KEY="sk_live_xxx"   # quotes become part of the value in some tools

Docker --env-file and some dashboards keep quotes literally. Paste raw values without quotes, and strip whitespace when reading if values come from copy-paste: ENV.fetch(name).strip.

.env was committed by mistake

Remove it from the repository (git rm --cached .env), add it to .gitignore, and rotate every secret it contained. Removing it from history doesn’t help if it was ever pushed, because it may already be cached or forked.

Author Perspective: configuration is code you can’t see

Environment variables are the part of an app that no code review ever sees, which is why I treat them with more suspicion than code. The AppConfig module with a REQUIRED_IN_PRODUCTION list has paid for itself on every project I’ve used it on. It turns “it works on staging but not in production” into a failed deploy with a precise message, and new developers read one file to learn what the app needs. The other habit I’d recommend is boring but effective: every time you add a variable, add it to .env.example in the same commit. Six months later, that file is the only documentation anyone will trust.

Environment variables on Enkihost

Managing configuration is easier when the platform injects the infrastructure variables for you, and you only handle your own. On Enkihost:

Enkihost

  • PostgreSQL and Redis add-ons inject DATABASE_URL and REDIS_URL, so infrastructure connection details never need to be copied by hand or stored in the repository.
  • Zero-downtime deploys: with boot-time validation, a release with a missing variable fails its checks while the current release keeps serving.
  • Per-app resource isolation: thread and worker counts set through RAILS_MAX_THREADS and WEB_CONCURRENCY apply to resources allocated to your app alone.
  • Plans for Rails and Sinatra: Ignite at 9 EUR per month after a 14-day free trial, with PostgreSQL and Redis included, or Blaze at 29 EUR with high availability and autoscaling. The free Spark plan is for Jekyll sites.

See enkihost.com.

FAQ

Does Rails load .env files automatically?

No. Rails does not read .env files on its own. Add the dotenv gem to the development and test groups, and it loads .env files when Rails boots in those environments. In production, set variables through the platform or container runtime instead of files.

Should I use ENV or Rails credentials for API keys?

Either works, but pick one per key and be consistent. Credentials keep secrets encrypted and versioned in git, with one key per environment. Environment variables let you change a value without a commit, and suit values the platform injects. Many teams keep third-party API keys in credentials and infrastructure URLs in ENV.

Why is my boolean environment variable always true?

Environment variable values are always strings, and any non-empty string, including false, is truthy in Ruby. Convert the value with ActiveModel::Type::Boolean.new.cast, which treats false, 0, off and an empty string as false.

How do I share environment variables between web and worker processes?

Use your platform’s grouping feature: Render Environment Groups, Upsun project-level variables, the env section of Kamal’s deploy.yml, which applies to all roles, or config vars on a single Heroku app, which apply to every process type. Make sure workers get every variable the web process needs.

Are environment variables secure for secrets?

They are secure enough for most apps when set through a platform’s encrypted secret store, because they never touch the repository. Their weaknesses are that any code in the process can read them and that they can leak through error reports or debug output, so avoid printing them and keep the number of people with access small.

Sources