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