Phoenix, Distillery, Kubernetes and runtime env variables?

I’m a little lost on how to achieve this.

Essentially, I just want my phoenix app (which is started through a distillery release) to use docker environment variables (which I set using Kubernetes secret).

I’ve got as far as fluctuating between confex and conform, but it doesn’t look like either solve my problem. I think confex is close, but I’m having trouble configuring it properly if it is indeed able to load runtime env variables from a distillery release.

The issue with conform is that though it loads config at runtime, it compiles .conf files into the distillery release, which, in my case, is contained within my docker image, and so would be a big security issue storing secrets within that image.

Anybody have any insight? Is confex able to do this and I just need to spend more time with it? I’m a couple hours in and not getting very far.

I use init callbacks with System.get_env for this.

In endpoint.ex

  @doc """
  Callback invoked for dynamically configuring the endpoint.

  It receives the endpoint configuration and checks if
  configuration should be loaded from the system environment.
  """
  @spec init(atom, Keyword.t()) :: {:ok, Keyword.t()} | no_return
  def init(_key, config) do
    if config[:load_from_system_env] do
      port = System.get_env("PORT") || raise("expected the PORT environment variable to be set")

      secret_key_base =
        System.get_env("SECRET_KEY_BASE") ||
          raise("expected the SECRET_KEY_BASE environment variable to be set")

      config =
        config
        |> Keyword.put(:http, [:inet6, port: port])
        |> Keyword.put(:secret_key_base, secret_key_base)

      {:ok, config}
    else
      {:ok, config}
    end
  end

and in repo.ex

  @doc """
  Callback invoked for dynamically configuring the repo.

  It receives the repo configuration and checks if
  configuration should be loaded from the system environment.
  """
  @spec init(atom, Keyword.t()) :: {:ok, Keyword.t()} | no_return
  def init(_type, config) do
    if config[:load_from_system_env] do
      db_url =
        System.get_env("DATABASE_URL") ||
          raise("expected the DATABASE_URL environment variable to be set")

      config = Keyword.put(config, :url, db_url)

      {:ok, config}
    else
      {:ok, config}
    end
  end

You would need to add load_from_system_env: true or something similar to your prod configs for your repo and web apps to know when to load vars from env and when not to.

ecto’s prod config

config :my_ecto_app, MyEctoApp.Repo,
  load_from_system_env: true, # <---
  pool_size: 20

phoenix’s prod config

config :my_phoenix_app, MyPhoenixApp.Endpoint,
  load_from_system_env: true, # <---
  url: [scheme: :https, host: "somehost.com", port: 443],
  cache_static_manifest: "priv/static/cache_manifest.json",
  version: Application.spec(:my_phoenix_app, :vsn),
  server: true,
  root: "."

Works fine with kubernetes and docker so far.

7 Likes

Cheers, looks like I’ll have to run with that.

My only gripe is appears it will become quite messy as you introduce more configuration variables?

1 Like

You can move the logic of init to it’s own function/module and clean it up somewhat.

@spec load_from_env(Keyword.t, [{atom, String.t}]) :: Keyword.t | no_return
def load_from_env(config, mappings) do
  Enum.reduce(mappings, config, fn {opt_key, env_var_name}, config ->
    env_var_value = System.get_env(env_var_name) || raise("expected #{env_var_name} to be set")
    Keyword.put(config, opt_key, env_var_value)
  end)
end
@spec init(atom, Keyword.t()) :: {:ok, Keyword.t()} | no_return
def init(_type, config) do
  if mappings = config[:load_from_system_env] do
    {:ok, load_from_env(config, mappings)}
  else
    {:ok, config}
  end
end

And define the opts that you load from the env like

config :my_app,
  load_from_system_env: [
    url: "DATABASE_URL",
    secret_key_base: "PHX_SECRET_KEY_BASE",
    # ...
  ]
2 Likes

Appreciate it!

I think I finally have my env loading. Will look at implementing something similar to what you’d done there as I expand on configuration. Cheers!

Do you have any suggestion in how I might set config for libraries at runtime? For example, I’m using Sentry…

config :sentry, dsn: "https://public:secret@app.getsentry.com/1",
  included_environments: [:prod],
  environment_name: Mix.env

But unsure how I can update that config based on the Endpoint callback.

I have no experience with sentry. Maybe they have an init callback as well if they start any processes?

But unsure how I can update that config based on the Endpoint callback.

init in endpoint only configures phoenix.

Possibly, but I have a feeling I’ll run into issues across the board as I integrate other libraries.

I’ve gone with:

REPLACE_OS_VARS=true rel/app foreground

…and in config.exs:

config :etc, variable: "${VARIABLE}"

Works, so I’m happy.

2 Likes

conform schema mapping has an option called env_var to pull value from env at runtime. The priority goes like .conf > env > default.

2 Likes

Ahh, good to know! I may try to tackle it again… but at the moment everything is working so won’t be touching it :stuck_out_tongue: