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.
Comments
No comments yet. Be the first.