Minimal Setup

View Source

Here is another demonstration of how you can configure your app for maximum simplicity: less is more. Per the 12-Factor App, this strategy helps "Minimize divergence between development and production". In some cases, the dev, prod, and test versions of the app can be identical (even down the the md5 hash) because the only differences are the configuration values supplied at runtime.

To see this strategy in action, we will abolish the dev.ex, prod.exs, and test.exs entirely and leave only the compile-time config (config/config.exs) and the runtime configuration (config/runtime.exs).

To try this out:

  1. Use the dot.new generator to generate a new application, e.g. mix dot.new sparse
  2. Delete the env-specific compile-time configs: config/dev.exs, config/prod.exs, and config/test.exs.
  3. Remove the line at the end of config/config.exs that uses import_config to load these other files.
  4. If environment-specific compile-time configurations are needed, update yourconfig/config.exs to include blocks for each config environment.

Here is an example of the stripped down config/config.exs file:

config/config.exs

# compile-time config
import Config

# Dev
if config_env() == :dev do
    config :logger, :console, 
        level: :debug,
        format: "[$level] $message\n"
end

# Test
if config_env() == :test do
    config :logger, :console, 
            level: :warning,
            format: "[$level] $message\n"
end

# Prod
if config_env() == :prod do
    config :logger, :console,
        format: "$time $metadata[$level] $message\n",
        level: :info,
        metadata: [:request_id]
end

Runtime vs. Compile-time Considerations

Logger configuration is an interesting example -- because the Logger relies on macros, it is affected by compile-time considerations. Although you can configure the logger level at runtime, that has a different effect than configuring it at compile-time. If you set the logger level to :info at runtime, then you will only see info, warning, or error messages in the logs... but the important thing is that the calls to Logger.debug/2 are still there in the code. Calls to those functions are still made; only the output is silenced.

By comparison, if you set the logger level to :info in the compile-time config, all calls to Logger.debug/2 are removed from the compiled application. Calls are no longer made to Logger.debug/2 because that function no longer exists. That distinction may not matter for small apps, but you can imagine that those milliseconds can add up for mission-critical applications where performance is paramount.

TL;DR: Sometimes it is better to have the flexibility to change the logging level at runtime (e.g. to help debug some tricky problem) and sometimes it's better to control this at compile-time. You decide.

Phoenix relies on macros to generate routes, so you must configure certain things at compile time: its routes are compiled into existence. Consider the following bits of configuration from a Phoenix application:

# Compile-time config
config :phoenix_live_view,
  debug_heex_annotations: true,
  enable_expensive_runtime_checks: true

# Enable dev routes for dashboard and mailbox (compile-time config)
config :your_app, dev_routes: true

Those must be defined at compile-time because it controls how the application gets built. You can put these types of configuration details into the appropriate environment block in the compile-time config.exs.

config/runtime.exs

Your runtime.exs can include whatever it needs to, i.e. all the application configuration that can happen at runtime and you know that the calls to Dotenv.source/2 will be responsible for loading up the proper .env files for the given environment.

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()
  ])


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?)

  # etc...

Optimizing Compilation Time

By default, your Elixir application compiles differently between environments, so you end up with multiple compiled artifacts in your _build directory. All this extra time spent compiling and re-compiling can really add up, so it's worth asking whether or not it's necessary.

If you have an app that can be fully configured at runtime and there are no differences between the compiled versions, then you can set the :build_per_environment option in yourmix.exs so that all environments use the same compiled code and you no longer need to re-compile your app between environments.

  def project do
    [
      app: :my_app,
      version: "0.1.0",
      elixir: "~> 1.18",
      start_permanent: Mix.env() == :prod,
      deps: deps(),
      releases: releases(),
      build_per_environment: false
    ]
  end

In some cases, you may be able to leverage this even if there are differences between dev and prod, but you'll have to explore the subtleties with your particular app. For example, if dev and test can share the same compiled artifacts, that may never conflict with the prod artifacts which may only need to be compiled on a build machine as part of your deployment pipeline. YMMV.

See Mix Project configuration for the official docs.

See Also

See the article about using Dotenvy in your application for more discussion on this and other approaches.