Dotenv for multiple environments

Lately I’ve been handling configuration entirely through environment variables for my apps, as the 12 Factor App recommends, and I can’t recommend this approach enough. As a constraint it helps me think about what parts of a given app actually need to vary from environment to environment, as opposed to what parts could potentially vary. The app I’m working on right now has zero YAML configuration files. It doesn’t even have a config directory.

The Dotenv gem has been essential to making this mostly config-less strategy workable. All of my development environment variables are contained in a .env file. I exclude this file from Git since it contains various API keys that are specific to me and my development setup.

The other day I realized I needed my setup to be a little different when running in the test environment. Specifically, I needed a different DATABASE_URL. Everything else, though, was the same as the development environment.

I did a little poking around, and discovered that while it’s not very well documented, Dotenv.load can accept multiple filenames. Here’s the code I ended up with in my startup file:

require 'dotenv'

  File.expand_path("../.#{APP_ENV}.env", __FILE__),
  File.expand_path("../.env",  __FILE__))

When running in a test environment, this code loads both a .test.env file (if it exists) and a .env file. The resulting ENV is a merge of both files.

A few notes:

  • When multiple files contain a variable with the same name, the leftmost file “wins”. So I put my “more specific” file on the left.
  • Dotenv takes care of checking to see if the file exists before loading it, so I don’t have to add any extra code to do that myself.
  • The dotenv-rails gem already has this built-in. My app is based on Sinatra and all of the startup is hand-rolled, so I needed to figure out how to do it manually.


  1. If you have no configuration files, and nothing committed to source control, how do you communicate to other developers what can and should be configured?

    I can think of two specific things that can be done…

    Every piece of code that requires configuration has a sensible default that works in the development environment. E.g., if this is a remote API, maybe the default is “disabled”. If it’s a local datastore, maybe the default is to raise an error with a message instructing the developer what to do. (The (test|spec)_helper can take care of it’s own environment.)

    There is a properly maintained .env-example file that is in source control. This would let developers see what can be done, and instruct them on where to get configuration values.

    I’m interested if these ideas are much different than how your team shares knowledge of configuration requirements.


    1. Right now I don’t have a team, so that simplifies things.

      If I had one, I’d probably commit a .env.example to Git.

      But I know that even I will eventually forget what all needs to be configured. So here’s the other thing I do: wherever an env var is used, I think about whether there is a sane default which will work both in production and development. If there is, I use ENV.fetch("FOO"){ the_sane_default } to provide it. Otherwise, I code it as ENV.fetch("FOO"), with no default block. That way if worse comes to worse and I’m at square one with no .env, if there are config variables I MUST set before the system will work right, it’ll just crash with a KeyError when it tries to fetch those values. Then I can set the variable in .env, run the app again, and see if it crashes on some other missing variable.

      1. I’ve been considering the best convention to set things up with .env.example and yet still be simple for new devs to get up and running as quick as possible. I think a key part is using .env.example and then being explicit in the README as to what to do with this file.

        Here’s an example from a README from a project I’ve been working on.


        $ git clone
        $ mv .env.example .env
        $ bundle install

  2. Good tip. I’ve been using direnv (, which uses a bash or zsh hook to accomplish the same thing, but I’ll have to compare the two approaches.

Leave a Reply

Your email address will not be published. Required fields are marked *