Configuration in Elixir with Dotenvy
By: Everett Griffiths
Tags: Configuration Management, Runtime, Programming, Elixir

If you have been fortunate enough to have written Elixir code, then you can understand its appeal: the straightforward beauty of functional programming, the ubiquitous ease of pattern matching, built-in testing and documentation, and all delivered with an elegant syntax that makes it a joy to work with.

However, one of most confusing things about Elixir applications is ironically one of the most fundamental: configuration. Because Elixir offers runtime flexibility like dynamic dispatching and duck typing, it can be easy to forget the nuances that exist because it is a compiled language, and compilation can make configuration more difficult.

The oily rag in your application just waiting to catch fire is the innocuous looking config/config.exs file. Consider something as straightforward as this:

# config/config.exs

# DON'T DO THIS!!! import Config config :myapp,

password: System.get_env("PASSWORD")

It may look harmless, but that might burn your app down. Why? Because anything in a config file is evaluated at compile time. Usually this is not what you want — usually you want to build your app in one place (e.g. via mix release in a Github workflow) and then ship it elsewhere to run. You may scratch your head wondering why the password keeps coming back nil even though your PASSWORD environment variable is set on your server. Alas, that ship has sailed because the password was read on the build machine when the app was compiled.

In your confusion, you may reach for the environment-specific configuration files ( config/dev.exs , config/test.exs , config/prod.exs ) as a “solution”, but I contend that in most cases, this is still wrong. There is a better way!

Runtime Config with runtime.exs

Since version 1.11, Elixir offers a runtime.exs config, which (as the name should suggest) is evaluated at runtime. So the sensible thing to do is to put your reading of environment variables into the runtime.exs :

# config/runtime.exs

import Config config :myapp,

password: System.get_env("PASSWORD")

This is a much better solution because now the app will operate as expected: if a system environment variable is set on the production machine, it will be read by the app running on that machine.

However, let’s take this even further.

No more Environment Specific Config Files!

I want to posit a radical (?) recommendation: you should abolish the environment-specific configuration files from your Elixir apps entirely. Before you flame me for suggesting we butcher that sacred cow, just consider the possibility of having only a config.exs and a runtime.exs . It’s possible. It works. Only you can judge whether this strategy is right for your app.

The guiding principle here is that if something can be configured at runtime, then it should be configured at runtime. Anything in your environment-specific config files should be moved to the runtime.exs . Comb through your config.exs and remove any dead hairs. The only things that should be left inside config.exs are things that must be configured at compile time, which it turns out, isn’t much. There are some low-level Logger and Kernel settings that have to be defined at compile time, but you might be surprised at how empty the compile-time config.exs file can be.

This strategy has important benefits. When you no longer have environment-specific configurations, you no longer have slightly different versions of your application. mix release builds the exact same artifact as MIX_ENV=prod mix release : it is 100% the same application in all cases, down to the md5 signature. There are no longer the weird little untested (or untestable!) corners of your app and you don’t get into trouble because the app you built (in the dev environment) and tested (in the test environment) is not the app that you actually end up running (in the prod environment). This is a big deal: the app is the app is the app. Mike drop.

Configuration in the Environment

Remember the wise advice of the 12-Factor App? It says unequivocally to “store config in the environment.” Taking this advice to heart means that we don’t have a dev.exs or a prod.exs : we do not have slightly different versions of the app. Instead, the adjectives “dev” or “prod” refer only to variables, e.g. this is the “prod” database URL. The app doesn’t know or care, it just needs a database URL (any database URL will do).

When we are in this mindset, we can properly appreciate a solution in our configuration layer: the dotenvy package lets us easily read “dotenv” files (e.g. .env ) into our runtime configuration. It merges the variables defined in a list of files and uses them to set system environment variables. By using System.fetch_env!/1 (or similar convenience functions), you are creating a strong contract between your app and its environment. Your app is saying “I need these system environment variables in order to run.” Starting the app without them will fail, and that is a good thing because it wouldn’t work anyway.

Here’s an example of our runtime.exs using dotenvy:

import Config

import Dotenvy source!(["#{config_env()}.env","#{config_env()}.local.env"])

config :myapp,

password: env!("PASSWORD", :string),

port: env!("PORT", :integer)

# ...

We can source multiple files using Dotenvy.source!/2 and leave room for overrides by looking for local files that are not versioned (e.g. .gitignore would list *.local.env in the above example) so you can group your configuration values by config environment (e.g. dev.env , test.env etc.).

The syntax of the dotenv files is simple key/value pairs:

# test.env

PASSWORD=xxxxxxxx

PORT=5432

The Dotenvy.env!/2 function provides type-casting for easily converting strings (all values read from system environment variables are strings) into the Elixir data types that your app requires, so it offers time-saving conveniences over System.fetch_env!/1

Finally I have a solution for configuring applications in Elixir that is as elegant and simple as Elixir itself. It looks and feels a lot like configuration conventions in other languages, and it is totally compatible with mix releases and deployments a la AWS or Heroku. I will be using dotenvy for my projects moving forward.