Improving your elixir configuration

Ambrose MungaiAmbrose Mungai
8 min read

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

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:

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

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

0
Subscribe to my newsletter

Read articles from Ambrose Mungai directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ambrose Mungai
Ambrose Mungai