Nuances of Elixir Releases for Phoenix Applications

Written by Sreenadh TC on August 18, 2020; tagged under elixir, phoenix, ecto

Deploying a phoenix application using Releases is very straightforward in most cases. However for people who are just starting off, certain steps of configuration can be a bit confusing. I’ll try to address some of those which I have encountered in my earlier days of developing and deploying Phoenix applications.

This post assumes that you already have atleast a basic Phoenix application that connects to a PostgreSQL database, and is ready to be deployed. For the sake of the post, I’ll call my application as Salmon.

Compile-time vs Run-time configurations - System environment variables

Configurations based on Environment variables is part and parcel of any 12Factor app. In addition to that, not all of them are available when we compile our application on our CI servers. Some of these environment variable values change based on Production and Staging environments.

The elixir config/*.exs files are all compile time configurations. If we add something like this in one of those files, we will get an error instead of a successful release:

# config/prod.exs
use Mix.Config
database_url = System.get_env("DATABASE_ECTO_URL") ||
    raise """
    environment variable DATABASE_ECTO_URL is missing.
    """

config :salmon, Salmon.Repo,
  url: database_url,
  pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")

If we run a mix release now, we get a Runtime error.

$ MIX_ENV=prod mix release
** (RuntimeError) environment variable DATABASE_ECTO_URL is missing.

While this is an expected outcome of our setup, we want this to happen when we start the server on the Production/Staging environment. We may not want to set the value of DATABASE_ECTO_URL when assembling the release.

Simply by moving these lines from config/prod.exs to config/releases.exs, Releases can configure the application to use runtime configurations.

$ MIX_ENV=prod mix release
* assembling salmon-1.0.0 on MIX_ENV=prod
* using config/releases.exs to configure the release at runtime

Release created at _build/prod/rel/salmon!

Compile-time vs Run-time configurations - Elixir Module attributes

Another scenario where the compile-time and run-time configuration can be confusing is in the use of Application.get_env/3 and Elixir module attrs. Module attributes in Elixir are configured during compile-time. One needs to be careful when using module attributes to store values configured via Environment Variables during run-time. Those values will not be reflected inside your app!
In other words, module attrs in Elixir should only be used to store constants which are available during compile-time. Everything else that happens in run-time should use functions. This includes the Application.get_env/3 app’s environment lookup.

# Don't
defmodule Salmon
  @base_url "https://world-fishes.com/api/v1"
  @api_access_token Application.get_env(:salmon, :api_access_token)

  def fetch_fishes() do
    ....
    headers = [
      {"token", @api_access_token},
      {"content-type", "application/json"}
    ]
    ....
  end
end

Even though we might set the salmon: :api_access_token based on the system env variable value when our OTP application starts, this still sets the value of @api_access_token to nil when compiling the application in environments like test and image builds.

Instead we should use functions to fetch the application configuration value in run-time as:

# Do
defmodule Salmon
  @base_url "https://world-fishes.com/api/v1"

  def fetch_fishes() do
    ....
    headers = [
      {"token", api_access_token()},
      {"content-type", "application/json"}
    ]
    ....
  end

  defp api_access_token, do: Application.get_env(:salmon, :api_access_token)
end

Integrating database and running migrations with Release artifacts

When we develop a phoenix application that has database dependency, we often come across the following mix commands:

$ mix ecto.create
$ mix ecto.migrate

These are nothing but the two commands used to create and migrate our database tables as per the schemas we have defined. However, when we use Releases to deploy the same application to a production environment, we might hit a roadblock.

While using docker for maintaining images for deployment, we often start from alpine to keep the image size to a bare minimum. On top of that, Elixir works smoothly with containerisation. This helps us build thin docker images by just using the release artifacts from _build directory. The Mix build tool is however not available in our release artifacts.

Running migrations as part of deployment is crucial, and luckily we have a neat little workaround for the same. Our release binary supports bin/salmon eval <expression> command that can be used to run Elixir expressions. All we have to do is to create a module within our Salmon app, which can run migrations with the help of Ecto!

# lib/salmon/release.ex
defmodule Salmon.Release do
  @app :salmon

  def migrate do
    load_app()
    maybe_create_db()

    for repo <- all_repos() do
      {:ok, _, _} =
        Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end

  def rollback(repo, version) do
    load_app()

    {:ok, _, _} =
      Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
  end

  defp all_repos do
    Application.fetch_env!(@app, :ecto_all_repos)
  end

  defp load_app do
    Application.load(@app)
  end

  defp maybe_create_db() do
    for repo <- all_repos() do
      :ok = ensure_repo_created(repo)
    end
  end

  defp ensure_repo_created(repo) do
    IO.puts("==> Create #{inspect(repo)} database if it doesn't exist")

    case repo.__adapter__.storage_up(repo.config) do
      :ok ->
        IO.puts("*** Database created! ***")
        :ok

      {:error, :already_up} ->
        IO.puts("==> Database already exist <(^_^)>")
        :ok

      {:error, term} ->
        {:error, term}
    end
  end
end

Now we can build the docker image and then deploy the same by running the below commands as part of docker run:

$ _build/prod/rel/salmon/bin/salmon eval "Salmon.Release.migrate"

$ _build/prod/rel/salmon/bin/salmon start

If you are deploying this to a Kubernetes cluster, you can use Jobs to run the eval migrate command.

The official Phoenix Documentation has helped me a long way in tackling the above nuances of releasing a Phoenix/Elixir application using Releases.

Hope you found this post helpful. Stay tuned to our blog, if you are interested in knowing how to deploy a similar application to a Kubernetes cluster. I’ll be writing about it in a future post!

If you have any questions or feedback, feel free to drop us a mail at team@codemancers.com.