LaunchKit
← All posts
· 11 min read · by The LaunchKit team · 0 views

Environment variables in Rails

Rails environment variables are just the process environment, and Ruby has read that through ENV since before Rails existed. There's no Rails layer over it, no config.env, no loader. What Rails adds is two things next to it: Rails.env, which is a string about which environment you booted, and encrypted credentials, which is where the secrets are supposed to live instead. Almost every confusion in this area comes from mixing up those three.

This post is mostly about the boring half, ENV.fetch against ENV[], because the boring half has a failure mode that costs an afternoon. It ends on a bug in this very repository, found while writing it, and which shipped in the repository's first commit.

ENV.fetch raises, ENV[] returns nil

ENV is a Hash-like object, and fetch behaves the way Hash#fetch does. Run against ruby 4.0.5:

ENV.fetch("NOPE_MISSING")
# KeyError: key not found: "NOPE_MISSING"

ENV["NOPE_MISSING"]
# => nil

That's the whole case for fetch. A required variable that is absent should stop the boot with the name of the variable in the message, at the line that wanted it. ENV["STRIPE_SECRET_KEY"] returns nil, the nil travels into an HTTP client, and what you get half a second later is an authentication error from Stripe that says nothing about your deploy. The KeyError says key not found: "STRIPE_SECRET_KEY" and you're done reading.

Two smaller facts that catch people out. ENV values are always strings, and assigning anything else raises TypeError: no implicit conversion of Integer into String. And an empty variable is a present variable, so ENV.fetch("EMPTY", "fallback") returned "" and not "fallback" when EMPTY was exported empty. A shell script that exports a blank value is indistinguishable, to fetch, from one that exports a real one. That is why this site reaches for .presence in places like app/controllers/robots_controller.rb:70, which reads ENV["APP_HOST"].presence&.sub(...) rather than trusting the key's existence.

The default argument changes your return type

The failure that actually bites is not about fetch against [] at all.

ENV["PORT_S"] = "15432"

ENV.fetch("PORT_S", 5432)       # => "15432"   (String)
ENV.fetch("NOPE_MISSING", 5432) # => 5432      (Integer)

The type of the return value depends on whether somebody exported the variable on this machine. Code that then does port > 1024, or uses it as a Hash key, or compares it to a literal, works on the laptop where nobody set it and breaks on the server where somebody did. Nothing raises at the fetch, so the stack trace points somewhere else entirely.

This repo has both halves of the lesson. lib/launchkit/doctor.rb:129 writes (ENV["DATABASE_PORT"] || 5432).to_i, which is the defensive form: coerce once, at the edge. config/database.yml:23 writes ENV.fetch("DATABASE_PORT", 5432) with no coercion at all, and gets away with it for a reason worth knowing. The file is ERB rendered into YAML, so both branches leave the same kind of thing on disk, the characters 15432 or the characters 5432, and YAML types them back to Integer either way. Confirmed both directions: YAML.load("port: 15432")["port"].class is Integer, and a real boot with DATABASE_PORT=15432 reported 15432 as an Integer in connection_db_config. The ERB round trip erased the problem. In plain Ruby, nothing erases it.

There is also a laziness difference, which matters if your default is expensive:

ENV.fetch("PORT_S", expensive_lookup)   # expensive_lookup always runs
ENV.fetch("PORT_S") { expensive_lookup } # runs only if the key is missing

A method that prints when called shows it. The second argument form printed even though the key was present. The block form did not. config/database.yml:20 uses the block form, ENV.fetch("RAILS_MAX_THREADS") { 5 }, and line 21 uses the argument form for a string literal, which is fine because a string literal costs nothing.

Rails reads no .env file, ever

The .env file is the part people are most sure about and most often wrong about. Rails does not load it. Reading .env isn't a feature that's off by default, it isn't a feature at all:

$ grep -rn '"\.env"' railties-8.1.3.1/lib/
$

Zero hits. Loading a .env file is the job of the dotenv gem, which you add yourself. bin/rails server with a .env sitting in the project root and no gem installed reads exactly nothing from it, and reports no error, because from Rails' point of view there is no file to miss.

Which brings me to the bug. This site ships .env.example, and the comment at the top of it says "Copy to .env (git-ignored) and adjust if needed." My .env exists and holds one line, DATABASE_PORT=15432, because PostgreSQL on this machine is not on 5432. And the Gemfile has no dotenv in it. So that file's promise is false, and has been since the repository's first commit on 27 July.

Booting with the shell variable unset:

$ unset DATABASE_PORT && bin/rails runner '...'
DATABASE_PORT=nil
dotenv defined? nil
{host: "localhost", port: 5432, database: "launchkit_codes_development"}

$ bin/doctor
[KO] PostgreSQL reachable - localhost:5432
     fix: start PostgreSQL (or set DATABASE_HOST / DATABASE_PORT)

And a query attempt gives ActiveRecord::ConnectionNotEstablished: connection to server at "::1", port 5432 failed: Connection refused. The fix I have been using without thinking about it is export DATABASE_PORT=15432 in the shell, which is why I'd never noticed the file was dead. The boilerplate at ~/RubymineProjects/launchkit ships the same .env.example, byte for byte, with the same missing gem, so a buyer copying it gets the same silence.

The funny part is that dotenv 3.2.0 is already in Gemfile.lock, at line 144. It arrives as a transitive dependency of kamal 2.12.0, which declares dotenv (~> 3.1). But kamal is in the Gemfile as gem "kamal", require: false, so nothing ever requires dotenv, no railtie is defined, and defined?(Dotenv) is nil at runtime. Installed isn't loaded.

What adding dotenv-rails would actually do

The gem is alive: dotenv 3.2.0 shipped on 3 December 2025, from Brandon Keepers, with 550 million downloads across all versions. It's maintained.

One naming detail. Since 3.0 the dotenv-rails gem is a one-line shim, and the whole file's body is require "dotenv". The railtie lives in the main gem at dotenv-3.2.0/lib/dotenv/rails.rb. Adding either name to the Gemfile works.

The railtie's initialize sets the file list, and the order is the interesting part (lib/dotenv/rails.rb:31):

files: [
  ".env.#{env}.local",
  (".env.local" unless env.test?),
  ".env.#{env}",
  ".env"
].compact,
overwrite: false

With overwrite: false, the first file in that list to define a key wins, and a variable already in the real environment beats all four. Against four scratch files with APP_HOST set in .env.local, .env.development and .env came out as the .env.local value, and a DATABASE_PORT pre-set in the process to 5555 stayed 5555 while .env said 15432. The mechanism is one line, dotenv.rb:52: filenames = filenames.reverse if overwrite. Precedence is implemented by reversing the array, not by a per-key check.

One thing to know before you commit a .env to a shared machine: the format runs shell commands. Dotenv.parse on a file containing WHOAMI=$(whoami) returned {"WHOAMI" => "mehdifarsi"}. Single quotes turn that off, and QUOTED='$(whoami)' came back as the literal string. A .env isn't inert data.

Rails.env.local? means development or test and nothing else

Rails.env isn't a String, it is an ActiveSupport::EnvironmentInquirer, confirmed at runtime. Rails 7.1 added local? to it, and the definition in active_support/environment_inquirer.rb is a constant and one assignment:

LOCAL_ENVIRONMENTS = %w[ development test ]
# ...
@local = in? LOCAL_ENVIRONMENTS

That's a frozen list, not a check for "not production". An app with a staging environment gets false from local?, which I confirmed by building the inquirer directly: ActiveSupport::EnvironmentInquirer.new("staging").local? returned false. If your staging box is meant to behave like development for one particular feature, local? is the wrong predicate and won't tell you so.

The same file, line 16, has a detail I liked: raise(ArgumentError, "'local' is a reserved environment name") if env == "local". You can't have a RAILS_ENV=local, because local? would then be ambiguous.

This site leans on the predicate hard, and correctly. config/routes.rb mounts the documentation engine, the SEO monitor and a UI kitchen sink behind Rails.env.local?, and the second one carries a second guard, && defined?(SeoMonitor::Engine), for a reason spelled out in a comment there: routes.rb raising takes the whole route table with it. The same instinct shows up in Kamal deploys of a Rails app, where what is and is not in the production image decides what the container can even attempt.

Credentials hold secrets, env vars hold the machine

The split this site uses, and the one I would defend, is in the .env.example comment itself: secrets go in encrypted credentials, and .env holds non-secret machine-specific config like a database port. config/app_config.rb:74 shows the seam: landing_template reads Rails.application.credentials.dig(:landing, :template) || ENV["LANDING_TEMPLATE"], credentials first, env var as a per-deploy override, because the landing template is not a secret.

The argument for credentials is not encryption for its own sake. It's that the value is versioned with the code that reads it. An env var set two deploys ago in a dashboard nobody can find is a configuration you cannot diff.

Except credentials don't remove the env var. They reduce it to one, and here is the bit worth checking. ENV["RAILS_MASTER_KEY"] takes precedence over the key file, and that holds even for per-environment credentials. This repo has config/credentials/development.key and config/credentials/development.yml.enc, no config/master.key at all. Booting with a deliberately wrong RAILS_MASTER_KEY:

ActiveSupport::MessageEncryptor::InvalidMessage: AEAD authentication tag verification failed
  at active_support/messages/codec.rb:57

The key file was sitting right there and was never consulted. An env var you forgot about in a CI config can break an app whose keys are all committed and correct.

The failure in the other direction is quieter. config.require_master_key defaults to false (railties-8.1.3.1/lib/rails/application/configuration.rb:77), so with no key at all the credentials object just answers nil to everything. Rails.application.credentials.nope returned nil, and dig(:nope, :nada) returned nil. Only the bang form complains, with KeyError: :nope is blank. This site handles that deliberately: AppConfig.check! logs [AppConfig] Not configured yet: ... and never raises, with a comment saying a fresh clone must boot with no keys so the founder can reach the setup wizard. That's a real trade, and it means a production deploy with a silently missing key gets a warning in a log rather than a refusal to boot. If you want the refusal, config.require_master_key = true in config/environments/production.rb is the switch.

The one .env-shaped file this repo really reads

One file in this project is in exactly dotenv format and genuinely gets parsed, and it isn't .env. It's .kamal/secrets, and the parser is kamal, not Rails. kamal-2.12.0/lib/kamal/secrets.rb:1 opens with require "dotenv" and line 39 calls secrets.merge!(::Dotenv.parse(secrets_file, overwrite: true)). Note overwrite: true, the opposite of the Rails railtie's default, because a deploy secret should win over whatever happens to be in your shell.

The file itself is why kamal drags dotenv into the lockfile, and why this site had a loaded copy of the gem all along. Its last uncommented line is

RAILS_MASTER_KEY=$(cat config/master.key)

which is the chicken and egg from the previous section written out: credentials hold every secret, and getting at them takes one environment variable, so one environment variable has to be delivered some other way. Kamal even replaces dotenv's command substitution with its own (kamal/secrets/dotenv/inline_command_substitution.rb) so it can control the escaping.

What I would change, and what would change my mind

The fix for this site is small and I am not going to sneak it in here: either add gem "dotenv-rails" to the Gemfile so .env.example starts telling the truth, or delete .env.example and put export DATABASE_PORT=15432 in the README next to the PostgreSQL setup. Both are honest. The current state, a template file promising a loader that isn't there, is the only one that isn't.

My preference is the second, and here is the position. For an app whose secrets are all in credentials, .env carries maybe three non-secret values, and adding a gem plus a railtie plus a four-file precedence order to deliver three values is a poor trade. The shell already does this. bin/build-boilerplate-zip even greps the git tree for a leaked .env before packaging, which tells you the file is treated as a liability rather than an asset.

What would change my mind is a team. One person can keep export DATABASE_PORT=15432 in a shell profile. Five people onboarding onto five differently-configured laptops cannot, and .env.example copied to .env is a genuinely good onboarding step when something actually reads it. The gem is maintained, it is already in the lockfile, and the cost is one line. If this repo ever has a second developer, add it that day.

The thing I have not solved: none of this tells you when a variable you rely on is gone. fetch raises at the moment of the read, which for config/database.yml is boot and for a feature flag buried in a controller is whenever somebody hits that action. A production deploy can come up perfectly healthy and be one request away from a KeyError nobody has hit yet. Reading every required variable once at boot, the way AppConfig::REQUIRED does for credentials, is the only answer I know, and this site has not extended it to env vars.

#rails #configuration

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.