Simple .env-like configuration in Elixir
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 (viaSystem.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 😎