# 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 environments
- `config/dev.exs` for development environments
- `config/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

```elixir
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

```elixir
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

```elixir
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

```elixir
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

```elixir
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:

1. Config that does not change depending on an environment should all be in the `config/config.exs` file
2. Config that varies by environment but is hardcoded should also be in the `config/config.exs` file inside `if` function blocks eg:
   ```elixir
   if config_env() == :test do
    config :swoosh, :api_client, false
   end
   ```
3. 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

```elixir
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

```elixir
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](https://12factor.net/config). 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](https://hexdocs.pm/dotenvy):

```elixir
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:

```elixir
# 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:

```elixir
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
```env
# 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](https://github.com/walu-lila/blog_demo/pull/2)
