Elixir by Example

Runtime Configuration

Every command ran when the site was built; the output is real Download project

A release is built once and then started on many machines: staging and production, three regions, a laptop. Each needs its own settings (database address, port, secrets), and secrets must never be baked into the build. But config/config.exs is evaluated when the release is built: whatever it reads, it reads on the build machine, and the value is frozen into the release.

config/runtime.exs is the answer: it's evaluated every time the release starts, on the machine it starts on, so it can read that machine's environment variables.

On this page:

  1. Both files read REGION. We build the release with REGION=build-box and start it with REGION=eu-west: config.exs still says build-box, runtime.exs says eu-west.
  2. runtime.exs requires API_KEY. Started without it, the release refuses to boot and says why.
  3. A value read with Application.compile_env/2 is frozen into the compiled code. Changing it at runtime is caught at boot.

The project

mix.exs
defmodule Settings.MixProject do
  use Mix.Project

  def project do
    [app: :settings, version: "0.1.0", elixir: "~> 1.20", deps: []]
  end
end
config/config.exs
import Config

# Evaluated when the release is BUILT, on the build machine.
config :settings, built_in: System.get_env("REGION", "unset")
config :settings, greeting: "hello"
config/runtime.exs
import Config

# Evaluated every time the release STARTS, on the machine it starts on.
config :settings, region: System.get_env("REGION", "unset")
config :settings, api_key: System.fetch_env!("API_KEY")

if greeting = System.get_env("GREETING") do
  config :settings, greeting: greeting
end

Settings.report/0 prints what each file set; Settings.greeting/0 returns a value fixed at compile time.

lib/settings.ex
defmodule Settings do
  # Read once, when this module is compiled, and frozen into its code.
  @greeting Application.compile_env(:settings, :greeting)

  def greeting, do: @greeting

  def report do
    IO.puts("config.exs said:  " <> Application.fetch_env!(:settings, :built_in))
    IO.puts("runtime.exs said: " <> Application.fetch_env!(:settings, :region))
  end
end

Build on one machine, start on another

The build sees REGION=build-box. Note the line saying the release will use config/runtime.exs at runtime.

$ REGION=build-box MIX_ENV=prod mix release
Compiling 1 file (.ex)
Generated settings app
* assembling settings-0.1.0 on MIX_ENV=prod
* using config/runtime.exs to configure the release at runtime

Release created at _build/prod/rel/settings

    # To start your system
    _build/prod/rel/settings/bin/settings start

Once the release is running:

    # To connect to it remotely
    _build/prod/rel/settings/bin/settings remote

    # To stop it gracefully (you may also send SIGINT/SIGTERM)
    _build/prod/rel/settings/bin/settings stop

To list all commands:

    _build/prod/rel/settings/bin/settings

Started with REGION=eu-west, the two files disagree: one value was decided at build time, the other just now.

$ REGION=eu-west API_KEY=k-123 _build/prod/rel/settings/bin/settings eval 'Settings.report()'
config.exs said:  build-box
runtime.exs said: eu-west

A missing secret stops the boot

Without API_KEY, System.fetch_env!/1 raises while the configuration is being read, and the release stops before any of the app runs. A loud, immediate failure beats an app that boots and fails on its first request.

$ REGION=eu-west _build/prod/rel/settings/bin/settings eval 'IO.puts(:booted)'
ERROR! Config provider Config.Reader failed with:
** (System.EnvError) could not fetch environment variable "API_KEY" because it is not set
    (elixir 1.20.5) lib/system.ex:731: System.fetch_env!/1
    _build/prod/rel/settings/releases/0.1.0/runtime.exs:5: (file)
    (elixir 1.20.5) src/elixir.erl:382: :elixir.eval_external_handler/3
    (stdlib 8.1.1) erl_eval.erl:1048: :erl_eval.do_apply/7
    (stdlib 8.1.1) erl_eval.erl:1664: :erl_eval.expr_list/7
    (stdlib 8.1.1) erl_eval.erl:1660: :erl_eval.expr_list/5
    (stdlib 8.1.1) erl_eval.erl:451: :erl_eval.expr/6
    (stdlib 8.1.1) erl_eval.erl:441: :erl_eval.expr/6

Runtime terminating during boot ({#{env=><<"API_KEY">>,'__struct__'=>'Elixir.System.EnvError','__exception__'=>true},[{'Elixir.System','fetch_env!',1,[{file,"lib/system.ex"},{line,731}]},{elixir_compiler,'__FILE__',1,[{file,"_build/prod/rel/settings/releases/0.1.0/runtime.exs"},{line,5}]},{elixir,eval_external_handler,3,[{file,"src/elixir.erl"},{line,382}]},{erl_eval,do_apply,7,[{file,"erl_eval.erl"},{line,1048}]},{erl_eval,expr_list,7,[{file,"erl_eval.erl"},{line,1664}]},{erl_eval,expr_list,5,[{file,"erl_eval.erl"},{line,1660}]},{erl_eval,expr,6,[{file,"erl_eval.erl"},{line,451}]},{erl_eval,expr,6,[{file,"erl_eval.erl"},{line,441}]}]})

Crash dump is being written to: erl_crash.dump...done

Compile-time values can't change at runtime

@greeting was set from compile_env during the build. Setting GREETING makes runtime.exs change that key, so the running app would disagree with its own compiled code. The release checks for this at boot and refuses.

$ API_KEY=k-123 GREETING=hi _build/prod/rel/settings/bin/settings eval 'IO.puts(Settings.greeting())'
ERROR! the application :settings has a different value set for key :greeting during runtime compared to compile time. Since this application environment entry was marked as compile time, this difference can lead to different behavior than expected:

  * Compile time value was set to: "hello"
  * Runtime value was set to: "hi"

To fix this error, you might:

  * Make the runtime value match the compile time one

  * Recompile your project. If the misconfigured application is a dependency, you may need to run "mix deps.clean settings --build"

  * Alternatively, you can disable this check. If you are using releases, you can set :validate_compile_env to false in your release configuration. If you are using Mix to start your system, you can pass the --no-validate-compile-env flag


Runtime terminating during boot ({<<"aborting boot">>,[{'Elixir.Config.Provider',boot,2,[]}]})

Crash dump is being written to: erl_crash.dump...done

Pitfall: anything config.exs (or prod.exs) reads from the environment comes from the build machine. Read environment variables only in runtime.exs, and keep secrets out of the files evaluated at build time.

Pitfall: use Application.compile_env/2 only for values that really must be known at compile time (they change the generated code). For everything else call Application.fetch_env!/2 when you need the value.

Pitfall: environment variables are inherited by every program your release starts and are easy to leak through logs or debug output. For real secrets, mount files from your platform's secret store and read those in runtime.exs.