View Source Strategies
Although there are other places where Dotenvy
may prove useful, it was designed with the config/runtime.exs
in mind: most of the following use-cases will focus on that because it offers a clean and declarative way to load up the necessary variables.
a-note-on-configuration-providers
A Note on Configuration Providers
Configuration providers are most often invoked in the context of releases, and although they can solve certain problems that arise in production deployments, they tend to be an awkward fit for regular day-to-day development. Dotenvy
seeks to normalize how configuration is loaded across environments, so having different methods depending on how you run your app is antithetical. We do not want some code that runs only in certain environments and not in others: it can make for untested or untestable code.
Secondly, configuration providers sometimes shift the task of "shaping" the configuration out of Elixir and into some static representation (e.g. JSON or TOML). The allure of a straight-forward static file is deceiving because there is no easy way to delineate Elixir-specific subtleties such as distinguishing between keyword lists and maps. When configuration providers "solve" one problem, they often create another: it can require some busywork to convert values back into Elixir variable types that your application requires.
For these reasons, Dotenvy
does not rely on configuration providers; dotenv files are an easier "lingua franca".
dotenv-for-dev-and-prod
Dotenv for Dev and Prod
The distinctions between "dev" and "prod" become less clear when we focus on configuration: ideally, the app is the same in all environments, it is only the configuration values themselves that can be described as "dev" or "prod" -- in this example they will live inside a single .env
file.
Let's look at the three files that will make this work:
config-config-exs
config/config.exs
# compile-time config
import Config
config :myapp,
ecto_repos: [MyApp.Repo]
config :myapp, MyApp.Repo,
migration_timestamps: [
type: :utc_datetime,
inserted_at: :created_at
]
config-runtime-exs
config/runtime.exs
import Config
import Dotenvy
source([".env", System.get_env()])
if config_env() == "test" do
config :myapp, MyApp.Repo,
database: "myapp_test",
username: "test-user",
password: "test-password",
hostname: "localhost",
pool_size: 10,
adapter: Ecto.Adapters.Postgres,
pool: Ecto.Adapters.SQL.Sandbox
else
config :myapp, MyApp.Repo,
database: env!("DATABASE", :string!),
username: env!("USERNAME", :string),
password: env!("PASSWORD", :string),
hostname: env!("HOSTNAME", :string!),
pool_size: env!("POOL_SIZE", :integer),
adapter: env("ADAPTER", :module, Ecto.Adapters.Postgres),
pool: env!("POOL", :module?)
end
env-dev-or-prod
.env
(dev or prod)
DATABASE=myapp_dev
USERNAME=myuser
PASSWORD=mypassword
HOSTNAME=localhost
POOL_SIZE=10
POOL=
The .env
shows some values suitable local development; if the app were deployed on a production box, it would be the same shape, but its values would point to a production database. For tests, values are hard-coded inside runtime.exs
. This is one admittedly heavy-handed way to ensure that your test runs don't accidentally hit the wrong database, but it does mean that there is a small block of untestable code inside the if-statement.
You may notice that in this example we have done away with config/dev.exs
, config/test.exs
, and config/prod.exs
. These should be used only when your app has a legitimate compile-time need. If you can configure something at runtime, you should configure it at runtime. These extra config files are omitted to help demonstrate how the decisions about how the app should run can often be pushed into runtime.exs
. This should help avoid confusion that often arises between compile-time and runtime configuration.
dotenvs-for-all-environments
Dotenvs for All Environments
It is possible to use only a config.exs
and a runtime.exs
file to configure
many Elixir applications: let the .env
tell the app how to run!
Consider the following setup:
config-config-exs-1
config/config.exs
# compile-time config
import Config
config :myapp,
ecto_repos: [MyApp.Repo]
config :myapp, MyApp.Repo,
migration_timestamps: [
type: :utc_datetime,
inserted_at: :created_at
]
config-runtime-exs-1
config/runtime.exs
import Config
import Dotenvy
source([".env", ".env.\#{config_env()}", System.get_env()])
config :myapp, MyApp.Repo,
database: env!("DATABASE", :string!),
username: env!("USERNAME", :string),
password: env!("PASSWORD", :string),
hostname: env!("HOSTNAME", :string!),
pool_size: env!("POOL_SIZE", :integer),
adapter: env("ADAPTER", :module, Ecto.Adapters.Postgres),
pool: env!("POOL", :module?)
env-dev-or-prod-1
.env
(dev or prod)
DATABASE=myapp_dev
USERNAME=myuser
PASSWORD=mypassword
HOSTNAME=localhost
POOL_SIZE=10
POOL=
env-test
.env.test
DATABASE=myapp_test
USERNAME=myuser
PASSWORD=mypassword
HOSTNAME=localhost
POOL_SIZE=10
POOL=Ecto.Adapters.SQL.Sandbox
The above setup would likely commit the .env.test
file so it was sure to override, and add .env
to .gitignore
, but other strategies are possible. The above example demonstrates developer settings appropriate for local development in the sample .env
file, but a production deployment would only differ in its values: the shape of the file would be the same.
The .env.test
file is loaded when running tests, so its values override any of the
values set in the .env
.
By using Dotenvy.env!/2
, a strong contract is created with the environment: the
system running this app must have the designated environment variables set somehow,
otherwise this app will not start (and a specific error will be raised).
Using the nil-able variants of the type-casting (those ending with ?
) is an easy
way to fall back to nil
when the variable contains an empty string: env!("POOL", :module?)
requires that the POOL
variable is set, but it will return a nil
if the value is an empty string.
See Dotenvy.Transformer
for more details.
releases
Releases
One of the hurdles when dealing with Elixir releases is that only certain files are packaged into them. One solution to this is to specify additional directories to include in the release via the overlays
option in your mix.exs
, e.g. an envs/
directory which contains your dotenv files:
# mix.exs
defp releases do
[
myapp: [
overlays: ["envs/"]
]
]
end
Since these files are copied to the root of your release, the relative paths used in your runtime.exs
will not be able to find them when your app is running in the context of a release. One solution to this is to rely on the RELEASE_ROOT
system environment variable which is set when a release is run. If this value exists, it will represent the fully qualified path to your release; this variable will not be set when running your app locally (e.g. during development).
We can use the presence of the RELEASE_ROOT
to determine a directory prefix for where to look for our dotenv files, e.g.:
import Config
import Dotenvy
# For local development, read dotenv files inside the envs/ dir;
# for releases, read them at the RELEASE_ROOT
config_dir_prefix =
System.fetch_env("RELEASE_ROOT")
|> case do
:error ->
"envs/"
{:ok, value} ->
IO.puts("Loading dotenv files from #{value}")
"#{value}/"
end
source!([
"#{config_dir_prefix}.env",
"#{config_dir_prefix}.#{config_env()}.env",
"#{config_dir_prefix}.#{config_env()}.local.env"
System.get_env()
])