Storing Elixir Release Configuration in Environment Variables with exrm
This post covers storing settings like database configuration in environment variables.
Software Versions
$ date -u "+%Y-%m-%d %H:%M:%S +0000"
2016-09-11 18:09:35 +0000
$ uname -vm
FreeBSD 12.0-CURRENT #0 r304324: Thu Aug 18 13:27:23 JST 2016 root@mirage.sennue.com:/usr/obj/usr/src/sys/MIRAGE_KERNEL amd64
$ mix hex.info
Hex: 0.13.0
Elixir: 1.3.2
OTP: 19.0.5
* snip *
$ mix phoenix.new -v
Phoenix v1.2.1
$ cat mix.lock | grep exrm | cut -d" " -f 3,6 | sed 's/[",]//g'
exrm: 1.0.8
Creating a Sample Project
Create a new Phoenix project.
mix phoenix.new phoenix_environment_settings --no-brunch
cd phoenix_environment_settings
mix ecto.create
Create a simple JSON memo endpoint.
mix phoenix.gen.json Memo memos title:string body:string
Revise web/router.ex. Uncomment the “/api” scope and add the “/memos” route.
web/router.ex.partial listing
scope "/api", PhoenixEnvironmentSettings do
pipe_through :api
resources "/memos", MemoController, except: [:new, :edit]
end
Run the migration.
mix ecto.migrate
Tests should pass.
mix test
Start the server.
mix phoenix.server
POST and GET a memo to make sure the server works. A prior post covers a shell script for conveniently interacting with this sample app.
# POST new
curl -H 'Content-Type: application/json' -X POST -d '{"memo": {"title": "New Title", "body": "This is the new memo body."}}' http://localhost:4000/api/memos
# GET id 1
curl -H 'Content-Type: application/json' http://localhost:4000/api/memos/1
Environment Variable Definitions
Define the following environment variables. Feel free to use different values and a static value for SECRET_KEY_BASE. Note that depending on how your database is configured, DB_PASSWORD may be unnecessary for connections from localhost.
export NODE_NAME=leaf_node
export COOKIE=thin_mints
export DB_USER=phoenix_environment_settings
export DB_PASSWORD=phoenix_environment_settings_password
export DB_NAME=phoenix_environment_settings_prod
export DB_HOST=localhost
export HOST=host.example.org
export PORT=7777
export SECRET_KEY_BASE=$(elixir -e ":crypto.strong_rand_bytes(48) |> Base.encode64 |> IO.puts")
Configuring the Application
Set server to true in config/prod.exs. Replace static configuration with dynamic configuration that will be pulled in from environment variables. None of this needs to be kept out version control, so it is safe to delete config/prod.secret.exs. Those settings have been merged in.
config/prod.exs
use Mix.Config
config :phoenix_environment_settings, PhoenixEnvironmentSettings.Endpoint,
http: [port: {:system, "PORT"}],
url: [host: "${HOST}", port: {:system, "PORT"}],
cache_static_manifest: "priv/static/manifest.json",
server: true,
root: ".",
version: Mix.Project.config[:version]
config :logger, level: :info
config :phoenix_environment_settings, PhoenixEnvironmentSettings.Endpoint,
secret_key_base: "${SECRET_KEY_BASE}"
config :phoenix_environment_settings, PhoenixEnvironmentSettings.Repo,
adapter: Ecto.Adapters.Postgres,
username: "${DB_USER}",
password: "${DB_PASSWORD}",
database: "${DB_NAME}",
hostname: "${DB_HOST}",
pool_size: 20
Alternatively, keep hard coded values in config/prod.secret.exs when running local release builds and use a version with dynamic environment variable settings in a real production environment.
config/prod.secret.exs
use Mix.Config
config :phoenix_environment_settings, PhoenixEnvironmentSettings.Endpoint,
secret_key_base: "${SECRET_KEY_BASE}"
config :phoenix_environment_settings, PhoenixEnvironmentSettings.Repo,
adapter: Ecto.Adapters.Postgres,
username: "${DB_USER}",
password: "${DB_PASSWORD}",
database: "${DB_NAME}",
hostname: "${DB_HOST}",
pool_size: 20
Optionally, add dynamic configuration with a default values to config/dev.exs. The environment variable replacement above is only suitable for releases. The interpreted solution below is only suitable for development with mix.
config/dev.exs partial listing
config :phoenix_environment_settings, PhoenixEnvironmentSettings.Endpoint,
http: [port: System.get_env("PORT") || 4000],
# ... at the end of the file ...
# Configure your database
config :phoenix_environment_settings, PhoenixEnvironmentSettings.Repo,
adapter: Ecto.Adapters.Postgres,
username: System.get_env("DB_USER") || "postgres",
password: System.get_env("DB_PASSWORD") || "postgres",
database: System.get_env("DB_NAME") || "phoenix_environment_settings_dev",
hostname: System.get_env("DB_HOST") || "localhost",
pool_size: 10
Adding Release Tasks
After deploying a release with exrm, mix is no longer available.
Define a module for release tasks with a mix ecto.migrate
equivalent.
Note that this is an Erlang module written in Elixir.
lib/release_tasks.ex
defmodule :release_tasks do
def migrate do
{:ok, _} = Application.ensure_all_started(:phoenix_environment_settings)
path = Application.app_dir(:phoenix_environment_settings, "priv/repo/migrations")
Ecto.Migrator.run(PhoenixEnvironmentSettings.Repo, path, :up, all: true)
:init.stop()
end
end
After generating a release, this task can be run as follows.
RELX_REPLACE_OS_VARS=true rel/phoenix_environment_settings/bin/phoenix_environment_settings command release_tasks migrate
The mix ecto.create
and mix ecto.drop
tasks are run less frequently
so it probably just makes sense to just manually create or drop the database
and user. PostgreSQL commands follow.
# PostgreSQL `mix ecto.create` equivalent
psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASSWORD}';"
createdb "${DB_NAME}"
psql -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} to ${DB_USER};"
# PostgreSQL `mix ecto.drop` equivalent
dropdb "${DB_NAME}"
dropuser "${DB_USER}"
# PostgresSQL interactive terminal
PGPASSWORD="${DB_PASSWORD}" psql -U "${DB_USER}" "${DB_NAME}"
The MySQL commands look like this.
# MySQL `mix ecto.create` equivalent
mysql -e "CREATE USER '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASSWORD}';"
mysql -e "CREATE DATABASE ${DB_NAME};"
mysql -e "GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASSWORD}';"
mysql -e "FLUSH PRIVILEGES;"
# MySQL `mix ecto.drop` equivalent
mysql -e "DROP DATABASE ${DB_NAME};"
mysql -e "DROP USER '${DB_USER}'@'localhost';"
# MySQL interactive terminal
mysql -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}"
Run the mix ecto.create
equivalent now.
Note that the password security in the above commands is less than ideal. Also, an existing superuser and password may need to be explicily specified when creating the new user and database.
Generating a Release
Add the elixir release manager (exrm) to mix.exs as a project dependency.
mix.exs partial listing
defp deps do
[{:phoenix, "~> 1.2.1"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_ecto, "~> 3.0"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 2.6"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.11"},
{:exrm, "~> 1.0.8"}, # this line is new
{:cowboy, "~> 1.0"}]
end
Install exrm and build a release. This will create the rel/ directory.
mix deps.get
mix deps.compile
MIX_ENV=prod mix compile
# brunch build --production # if using brunch
MIX_ENV=prod mix phoenix.digest
MIX_ENV=prod mix release
Run the migration task defined in the Adding Release Tasks section.
RELX_REPLACE_OS_VARS=true rel/phoenix_environment_settings/bin/phoenix_environment_settings command release_tasks migrate
Environment variables will be used as knobs to configure the app. Note that the RELX_REPLACE_OS_VARS=true environment variable needs to be defined to use environment variables for dynamic configuration.
The rel/vm.args file is primarily used to configure the erlang VM. It can also be used to define application configuration parameters. Application configuration parameters defined in this file can be passed into the program as atoms or integers. Note that the location of this file can be configured with the RELEASE_CONFIG_DIR environment variable. Add the following to rel/vm.args.
rel/vm.args
## Name of the node
-name ${NODE_NAME}
## Cookie for distributed erlang
-setcookie ${COOKIE}
## App Settings
-phoenix_environment_settings port ${PORT}
Alternatively, rel/sys.config can be used to pass in application configuration parameters. This file is written in Erlang.
rel/sys.config
[
{phoenix_environment_settings, [
{port, "${PORT}"}
]}
].
The Elixir config/config.exs file is probably a better place to define non-VM settings for an Elixir application. It is ultimately merged with rel/sys.config. The exact settings for this project were covered in the Configuring the Application section above.
Rebuild the release with the configuration files.
MIX_ENV=prod mix compile
# brunch build --production # if using brunch
MIX_ENV=prod mix phoenix.digest
MIX_ENV=prod mix release
Start the release in the console.
RELX_REPLACE_OS_VARS=true rel/phoenix_environment_settings/bin/phoenix_environment_settings console
Make sure the server responds.
curl -H 'Content-Type: application/json' -X POST -d '{"memo": {"title": "Memo A", "body": "Alpha memo body."}}' "http://localhost:${PORT}/api/memos"
curl -H 'Content-Type: application/json' -X POST -d '{"memo": {"title": "Memo B", "body": "Beta memo body."}}' "http://localhost:${PORT}/api/memos"
curl "http://localhost:${PORT}/api/memos"
Exit the console with ^C.
Custom Application Settings
Add this configuration gist to lib/config.ex.
This configuration wrapper allows the same convenient
{:system, "VARIABLE", "default"}
convention to be used with both mix and releases.
Note that this will not help configure things like PhoenixEnvironmentSettings.Repo
because they were not written to get settings via this module.
The linked to version has the typespecs and documentation.
lib/config.ex
defmodule Config do
def get(app, key, default \\ nil) when is_atom(app) and is_atom(key) do
case Application.get_env(app, key) do
{:system, env_var} ->
case System.get_env(env_var) do
nil -> default
val -> val
end
{:system, env_var, preconfigured_default} ->
case System.get_env(env_var) do
nil -> preconfigured_default
val -> val
end
nil ->
default
val ->
val
end
end
def get_integer(app, key, default \\ nil) do
case get(app, key, nil) do
nil -> default
n when is_integer(n) -> n
n ->
case Integer.parse(n) do
{i, _} -> i
:error -> default
end
end
end
end
Add these lines to config/config.exs.
config/config.exs partial listing
config :phoenix_environment_settings,
ecto_repos: [PhoenixEnvironmentSettings.Repo],
welcome_message: {:system, "WELCOME_MESSAGE", "Hello, world!"},
magic_number: {:system, "MAGIC_NUMBER", 42}
Access the settings in iex -S mix
like this.
You should get the default values.
iex -S mix
Config.get :phoenix_environment_settings, :welcome_message
Config.get_integer :phoenix_environment_settings, :magic_number
Define the environment variables and run the commands in iex again. You should get the environment variable values this time.
export WELCOME_MESSAGE="Welcome. Try, but there is no escape."
export MAGIC_NUMBER=-1
This is useful for defining environment variable knobs to control run time behavior. It is not a solution for problems that rely on compile time behavior, like using environment variables to specify dynamic routes.
Other Considerations
Note that when using RELX_REPLACE_OS_VARS=true, the environment variables in rel/sys.conf and config/config.exs will always be replaced with strings. The following almost certainly does not work as expected.
export DB_POOL_SIZE=20
config/prod.exs or config/prod.secret.exs partial listing
pool_size: "${DB_POOL_SIZE}"
The following will work in development, but not production.
config/dev.exs partial listing
pool_size: (System.get_env("DB_POOL_SIZE") || "10") |> String.to_integer
If integers or atoms need to be passed in directly, use vm.args. The author could not figure out how to pass DB_POOL_SIZE to Repo via vm.args.
Next Steps
Consider looking into distillery, the “new exrm” written in pure Elixir. Also consider looking into conform, a library for working with init-style configuration. For deployment, edeliver is worth looking at.
References:
- Elixir Release Manager (exrm)
- Elixir Release Manager Configuration
- Elixir Release Manager, How to config environment variables with Elixir and Exrm
- Elixir Release Manager, Running migration in an Exrm release
- Elixir Release Manager, How to run Ecto migrations from an exrm release
- Elixir, Useful Config Wrapper Gist
- Elixir, Understanding Config in Elixir
- Elixir as a Service on FreeBSD
- Elixir, conform
- Elixir, edeliver
- Erlang, man erl
- Erlang, man config
- Phoenix, Dynamic Dispatch Gist
- Phoenix, A Shell Script for Working with Phoenix JSON APIs
- Phoenix as a Service on FreeBSD
- Mix, Accessing Mix tasks from release
- Mix, exrm PostgreSQL/MySQL release equivalents for
mix ecto.create
andmix ecto.drop
. - Distillery
- Distillery, Runtime Configuration