Improving your elixir configuration

Updates
2025-05-20
- Fix bugs in config
- Add example
.env
file - Add GitHub link to PR
Main article
Configuration in the Elixir ecosystem has undergone multiple changes over the years. Configuration scripts are used to configure elixir applications during the compile phase depending on the environment in which it is running ie:
config/test.exs
for test environmentsconfig/dev.exs
for development environmentsconfig/prod.exs
for prodution environments.
Overtime this resulted in problems since a new file needed to be added for every new environment, eg: staging environment, requiring compilation of the code again. This would not work in CI/CD environments since the same binary that was tested is expected to be used in production.
In Elixir 1.11 support for using config/runtime.exs
which allowed for runtime configuration in all environments. The current structure of the config folder is as follows:
config
├── config.exs
├── dev.exs
├── prod.exs
├── runtime.exs
└── test.exs
Current config file structures
If we look deeper into each of the configuration files with all the comments removed we can see that there is a lot of duplication.
Config file
import Config
config :myapp, :scopes,
user: [
default: true,
module: Myapp.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :id,
schema_table: :users,
test_data_fixture: Myapp.AccountsFixtures,
test_login_helper: :register_and_log_in_user
]
config :myapp,
ecto_repos: [Myapp.Repo],
generators: [timestamp_type: :utc_datetime]
config :myapp, MyappWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: MyappWeb.ErrorHTML, json: MyappWeb.ErrorJSON],
layout: false
],
pubsub_server: Myapp.PubSub,
live_view: [signing_salt: "GkZs+o4a"]
config :myapp, Myapp.Mailer, adapter: Swoosh.Adapters.Local
config :esbuild,
version: "0.17.11",
myapp: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
config :tailwind,
version: "4.0.9",
myapp: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/css/app.css
),
cd: Path.expand("..", __DIR__)
]
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
config :phoenix, :json_library, Jason
import_config "#{config_env()}.exs"
The last line import_config "#{config_env()}.exs"
reads the config file associated with the current environment.
Test config file
import Config
config :bcrypt_elixir, :log_rounds, 1
config :myapp, Myapp.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "myapp_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
config :myapp, MyappWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "6tvc42QacLkrYkrabIP+Xhj0LFaiF7bCNp4kMY5VT41zVVvFHywZB0Tq/F5PmjSJ",
server: false
config :myapp, Myapp.Mailer, adapter: Swoosh.Adapters.Test
config :swoosh, :api_client, false
config :logger, level: :warning
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view, enable_expensive_runtime_checks: true
Dev config file
import Config
config :myapp, Myapp.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "myapp_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
config :myapp, MyappWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4000],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "2UQSRkWSJcN3LSaNF6ZclG2Zv8ZpuEuGXDIiFSkE8z8a1AczuxJDDTGc3trNoQQu",
watchers: [
esbuild: {Esbuild, :install_and_run, [:myapp, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:myapp, ~w(--watch)]}
]
config :myapp, MyappWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/myapp_web/(controllers|live|components)/.*(ex|heex)$"
]
]
config :myapp, dev_routes: true
config :logger, :default_formatter, format: "[$level] $message\n"
config :phoenix, :stacktrace_depth, 20
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
debug_heex_annotations: true,
enable_expensive_runtime_checks: true
config :swoosh, :api_client, false
Prod config file
import Config
config :myapp, MyappWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
config :swoosh, api_client: Swoosh.ApiClient.Req
config :swoosh, local: false
config :logger, level: :info
Runtime config file
import Config
if System.get_env("PHX_SERVER") do
config :myapp, MyappWeb.Endpoint, server: true
end
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :myapp, Myapp.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
config :myapp, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :myapp, MyappWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base
end
If we look at the above code we can see alot of duplication of application configuration.
Adding new configuration needs updates to 3 files. Also, the config/runtime.exs
reads environment variables in production mode. There is no reason that we cannot use environmental variables to configure all the environments instead of production only.
Configuration improvements
In order to improve the configuration we can combine the configuration into 2 files config/config.exs
and config/runtime.exs
only. To do this we have to follow the following rules:
- Config that does not change depending on an environment should all be in the
config/config.exs
file - Config that varies by environment but is hardcoded should also be in the
config/config.exs
file insideif
function blocks eg:if config_env() == :test do config :swoosh, :api_client, false end
- All other configuration should be stored in the
config/runtime.exs
file and should parse operating system environment variables.
The new config folder should look as:
config
├── config.exs
└── runtime.exs
New config.exs
file
import Config
config :myapp, :scopes,
user: [
default: true,
module: Myapp.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :id,
schema_table: :users,
test_data_fixture: Myapp.AccountsFixtures,
test_login_helper: :register_and_log_in_user
]
config :myapp,
ecto_repos: [Myapp.Repo],
generators: [timestamp_type: :utc_datetime]
config :myapp, MyappWeb.Endpoint,
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: MyappWeb.ErrorHTML, json: MyappWeb.ErrorJSON],
layout: false
],
pubsub_server: Myapp.PubSub,
live_view: [signing_salt: "GkZs+o4a"],
code_reloader: config_env() == :dev,
debug_errors: config_env() == :dev,
check_origin: config_env() == :prod
config :esbuild,
version: "0.17.11",
myapp: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
config :tailwind,
version: "4.0.9",
myapp: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/css/app.css
),
cd: Path.expand("..", __DIR__)
]
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
config :phoenix, :json_library, Jason
if config_env() == :test do
config :bcrypt_elixir, :log_rounds, 1
config :myapp, Myapp.Repo,
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
config :logger, level: :warning
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view, enable_expensive_runtime_checks: true
end
if config_env() == :dev do
config :myapp, MyappWeb.Endpoint,
watchers: [
esbuild: {Esbuild, :install_and_run, [:myapp, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:myapp, ~w(--watch)]}
],
live_reload: [
web_console_logger: true,
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/myapp_web/(controllers|live|components)/.*(ex|heex)$"
]
]
config :myapp, dev_routes: true
config :logger, :default_formatter, format: "[$level] $message\n"
config :phoenix, :stacktrace_depth, 20
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
debug_heex_annotations: true,
enable_expensive_runtime_checks: true
end
if config_env() == :prod do
config :logger, level: :info
end
New runtime.exs
config file
import Config
database_username =
System.get_env("DATABASE_USERNAME") ||
raise """
environment variable DATABASE_USERNAME is missing.
For example: postgress
"""
database_password =
System.get_env("DATABASE_PASSWORD") ||
raise """
environment variable DATABASE_PASSWORD is missing.
For example: postgress
"""
database_hostname =
System.get_env("DATABASE_HOSTNAME") ||
raise """
environment variable DATABASE_HOSTNAME is missing.
For example: localhost
"""
database_database =
System.get_env("DATABASE_DATABASE") ||
raise """
environment variable DATABASE_DATABASE is missing.
For example: myapp
"""
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
endpoint_url_host =
System.get_env("ENDPOINT_URL_HOST") ||
raise """
environment variable ENDPOINT_URL_HOST is missing.
For example: "example.com"
"""
endpoint_url_port =
System.get_env("ENDPOINT_URL_PORT") ||
raise """
environment variable ENDPOINT_URL_PORT is missing.
For example: "80"
"""
endpoint_http_port =
System.get_env("PORT") ||
raise """
environment variable PORT is missing.
For example: "4000"
"""
config :myapp, Myapp.Repo,
username: database_username,
password: database_password,
hostname: database_hostname,
database: database_database,
stacktrace: config_env() == :dev,
show_sensitive_data_on_connection_error: config_env() == :dev,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: if(System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []),
if config_env() == :test do
config :myapp, Myapp.Repo,
database: "#{database_database}#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
end
config :myapp, MyappWeb.Endpoint,
url: [
host: endpoint_url_host,
port: String.to_integer(endpoint_url_port),
scheme: if(config_env() == :prod, do: "https", else: "http")
],
http: [
port: String.to_integer(endpoint_http_port),
ip: if(config_env() == :prod, do: {0, 0, 0, 0, 0, 0, 0, 0}, else: {127, 0, 0, 1})
],
secret_key_base: secret_key_base,
server: String.to_existing_atom(System.get_env("PHX_SERVER", "false"))
swooch_adapter =
case config_env() do
:dev -> Swoosh.Adapters.Local
# In test we don't send emails.
:test -> Swoosh.Adapters.Test
:prod -> Swoosh.Adapters.MailGun
end
config :myapp, Myapp.Mailer,
adapter: Swoosh.Adapters.Test,
api_client: false,
local: true
if config_env() == :dev do
config :myapp, Myapp.Mailer, adapter: Swoosh.Adapters.Local
end
if config_env() == :prod do
config :myapp, Myapp.Mailer,
api_key: System.get_env("MAILGUN_API_KEY"),
domain: System.get_env("MAILGUN_DOMAIN"),
local: false,
api_client: Swoosh.ApiClient.Req
config :myapp, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
end
Setting environmental variables
The configuration has been changed to use environmental variables, this is not a problem in production where it is usually recommended to use in the 12 factor. However in development and testing this can be a problem since it is not easy to set the environmental variables in the operation system. To simply this we will update the config to make use .env
files.
To do this we will need to add an external dependency Dotenvy:
def deps do
[
{:dotenvy, "~> 1.0.0"}
]
end
Then run mix deps.get
The config/runtime.exs
file is updated to include the following block at the beginning:
# config/runtime.exs
import Config
import Dotenvy
env_dir_prefix = System.get_env("RELEASE_ROOT") || Path.expand("./envs")
source!([
Path.absname(".env", env_dir_prefix),
Path.absname(".#{config_env()}.env", env_dir_prefix),
Path.absname(".#{config_env()}.overrides.env", env_dir_prefix),
System.get_env()
])
The above example would include the envs/.env
file for shared/default configuration values and then leverage environment-specific .env
files (e.g. envs/.dev.env
) to provide environment-specific values. An envs/.{MIX_ENV}.overrides.env
file would be referenced in the .gitignore
file to allow developers to override any values a file that is not under version control. System environment variables are given final say over the values via System.get_env()
.
Dotenvy.env!/2
is used to read the given variable
and convert its value to a given type. This function will raise an error if the given variable does not exist.
Updated config/runtime.exs
file:
config :myapp, Myapp.Repo,
username: env!("DATABASE_USERNAME"),
password: env!("DATABASE_PASSWORD"),
hostname: env!("DATABASE_HOSTNAME"),
database: env!("DATABASE_DATABASE"),
stacktrace: config_env() == :dev,
show_sensitive_data_on_connection_error: config_env() == :dev,
pool_size: env!("POOL_SIZE", :integer, 10),
socket_options: if(env!("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [])
if config_env() == :test do
config :myapp, Myapp.Repo,
database: "#{env!("DATABASE_DATABASE")}#{env!("MIX_TEST_PARTITION", :integer, 1)}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
end
config :myapp, MyappWeb.Endpoint,
url: [
host: env!("ENDPOINT_URL_HOST"),
port: env!("ENDPOINT_URL_PORT", :integer),
scheme: if(config_env() == :prod, do: "https", else: "http")
],
http: [
port: env!("PORT", :integer),
ip: if(config_env() == :prod, do: {0, 0, 0, 0, 0, 0, 0, 0}, else: {127, 0, 0, 1})
],
secret_key_base: env!("SECRET_KEY_BASE"),
server: env!("PHX_SERVER", :boolean, false)
config :myapp, Myapp.Mailer,
adapter: Swoosh.Adapters.Test,
api_client: false,
local: true
if config_env() == :dev do
config :myapp, Myapp.Mailer, adapter: Swoosh.Adapters.Local
end
if config_env() == :prod do
config :myapp, Myapp.Mailer,
adapter: Swoosh.Adapters.MailGun,
api_key: env!("MAILGUN_API_KEY"),
domain: env!("MAILGUN_DOMAIN"),
local: false,
api_client: Swoosh.ApiClient.Req
config :myapp, :dns_cluster_query, env!("DNS_CLUSTER_QUERY")
end
Example .env
file
# Database Configuration
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
DATABASE_HOSTNAME=localhost
DATABASE_DATABASE=myapp_dev
POOL_SIZE=10
ECTO_IPV6=false
# Phoenix Endpoint
PHX_SERVER=false
ENDPOINT_URL_HOST=localhost
ENDPOINT_URL_PORT=80
PORT=4000
SECRET_KEY_BASE=your_secret_key_here
PHX_SERVER=true
# Mailer (for production)
MAILGUN_API_KEY=your-mailgun-api-key
MAILGUN_DOMAIN=your-mailgun-domain.com
# Optional clustering support
DNS_CLUSTER_QUERY=myapp.local
# Testing (optional for test partitioning)
MIX_TEST_PARTITION=1
Conclusion
By consolidating configuration into just two files, using runtime variables, and leveraging .env support with Dotenvy, your Elixir app becomes:
- Easier to manage across environments
- CI/CD-friendly and immutable-build ready
- Aligned with 12-factor and containerized deployment standards
To view the code here is the PR for the code changes
Subscribe to my newsletter
Read articles from Ambrose Mungai directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
