Simple .env-like configuration in Elixir

Yuriy Kharchenko
3 min readMar 12, 2024

First of all, why do we need .env files in Elixir?

Like in other programming languages, we might want to be able to configure credentials for local development without adding them to source control. A typical use case is database configuration where username and password may differ for each developer’s local environment.

In a freshly created Phoenix app database credentials are hardcoded in dev.exs and test.exs, for example:

# config/dev.exs

import Config

# Configure your database
config :my_app, MyApp.Repo,
username: "postgres",
password: "postgres",
...

And we want username and password to be sourced from env variables:

# config/dev.exs

import Config

# Configure your database
config :my_app, MyApp.Repo,
username: System.get_env("DB_USERNAME"),
password: System.get_env("DB_PASSWORD"),
...

As we know, dev.exs is included in config.exs and here is what the documentation says about it:

config/config.exs — this file is read at build time, before we compile our application and before we even load our dependencies. This means we can't access the code in our application nor in our dependencies. However, it means we can control how they are compiled.

That means the following code won’t work if we install Dotenv or any other .env Elixir lib and try loading it in dev.exs:

# config/dev.exs

import Config

# Oops, Dotenv is not available here.
Dotenv.load()

# Configure your database
...

So if we want to use dependencies, we should move the db configuration to runtime.exs:

config/runtime.exs — this file is read after our application and dependencies are compiled and therefore it can configure how our application works at runtime. If you want to read system environment variables (via System.get_env/1) or any sort of external configuration, this is the appropriate place to do so

Bloating runtime.exs with non-production configurations might be not a good idea and if we prefer to keep our dev and test settings isolated in separate files and keep using dotenv configs at the same time, our options would be to parse .env manually in dev.exs or source it externally before the app starts. But both of the two approaches have a number of cons.

Despite the simplicity of dotenv syntax, when parsing it manually we’ll have to care about whitespace, comments, quotes, variable names, etc.

Sourcing it externally complicates the developer experience as we’ll need to remember to source .env, and in case dev and test envs have different settings it will get even more complicated.

Alternative solution

However, an alternative and equally effective approach involves using plain Elixir scripts (.exs files) to achieve a similar outcome without the need for additional dependencies. This method not only simplifies the process but also integrates seamlessly with Elixir's existing configuration mechanisms.

Start by creating a new file named .env.exs within the config directory of your Elixir project.

# .env.exs

System.put_env("DB_USERNAME", "myusername")
System.put_env("DB_PASSWORD", "mypassword")

In this example, we’re setting the database username and password as environment variables using the System.put_env/2 function. These variables are now accessible throughout your application.

Next, you’ll need to ensure that your project loads the configurations defined in .env.exs. Edit your config.exs file to include the .env.exs file conditionally based on the application's environment.

# config.exs

...

# Add this before importing dev.exs and test.exs
if config_env() in [:dev, :test] do
import_config ".env.exs"
end

import_config "#{config_env()}.exs"

Don’t forget to add .env.exs to .gitignore

# .gitignore

/config/.env.exs

Make it a bit smarter

Let’s make dotenv files optional and allow env-specific configs like .env.dev.exs to override the base .env.exs so it imitates the behaviour of dotenv libs.

# config.exs

if config_env() in [:dev, :test] do
for path <- [".env.exs", ".env.#{config_env()}.exs"] do
path = Path.join(__DIR__, "..") |> Path.join("config") |> Path.join(path) |> Path.expand()
if File.exists?(path), do: import_config(path)
end
end

Now we have a full featured dotenv config with zero external dependencies. Just a few lines of Elixir code 😎

--

--