Running Ecto migrations in production releases with Distillery custom commands

By Jake Morrison in DevOps on Fri 24 May 2019

In a dev or test environment, we execute the mix ecto.migrate command to run database migrations. When running from a release, however, the mix command is not available. Instead, we need to execute Ecto.Migrator.run/4 from code. We do this by adding a Distillery custom command called migrate which we call from the release script, e.g.:

/srv/foo/current/bin/foo migrate

The fundamentals of defining and running a migration are covered in this article. In a more complex app, however, we need a bit more in our script to initialize the environment.

First, in rel/config.exs, define the migrate command:

set commands: [
    migrate: "rel/commands/migrate.sh"
]

Next, create the script rel/commands/migrate.sh, changing Foo to match your project module name:

release_ctl eval --mfa "Foo.Tasks.Migrate.run/1" --argv -- "$@"

Finally, add the Elixir code which runs the database migrations.

The main point is that we need to initialize the database connection information the same way as your application normally does, e.g. by loading a runtime file like /etc/foo/config.toml or setting environment variables.

Following is a working example. Where you see CHANGEME, use the name of your project.

defmodule Foo.Tasks.Migrate do
  @moduledoc "Mix task to run Ecto database migrations"

  # CHANGEME: Name of app as used by Application.get_env
  @app :foo
  # CHANGEME: Name of app repo module
  @repo_module Foo.Repo

  def run(_args) do
    ext_name = @app |> to_string |> String.replace("_", "-")
    config_dir = Path.join("/etc", ext_name)

    config_exs = Path.join(config_dir, "config.exs")
    if File.exists?(config_exs) do
      IO.puts "==> Loading config file #{config_exs}"
      Mix.Releases.Config.Providers.Elixir.init([config_exs])
    end

    config_toml = Path.join(config_dir, "config.toml")
    if File.exists?(config_toml) do
      IO.puts "==> Loading config file #{config_toml}"
      Toml.Provider.init([path: config_toml])
    end

    repo_config = Application.get_env(@app, @repo_module)
    repo_config = Keyword.put(repo_config, :adapter, Ecto.Adapters.Postgres)
    Application.put_env(@app, @repo_module, repo_config)

    # Start requisite apps
    IO.puts "==> Starting applications.."
    for app <- [:crypto, :ssl, :postgrex, :ecto, :ecto_sql] do
      {:ok, res} = Application.ensure_all_started(app)
      IO.puts "==> Started #{app}: #{inspect res}"
    end

    # Start repo
    IO.puts "==> Starting repo"
    {:ok, _pid} = apply(@repo_module, :start_link, [[pool_size: 2, log: :info, log_sql: true]])

    # Run migrations for the repo
    IO.puts "==> Running migrations"
    priv_dir = Application.app_dir(@app, "priv")
    migrations_dir = Path.join([priv_dir, "repo", "migrations"])

    opts = [all: true]
    config = apply(@repo_module, :config, [])
    pool = config[:pool]
    if function_exported?(pool, :unboxed_run, 2) do
      pool.unboxed_run(@repo_module, fn -> Ecto.Migrator.run(@repo_module, migrations_dir, :up, opts) end)
    else
      Ecto.Migrator.run(@repo_module, migrations_dir, :up, opts)
    end

    # Shut down
    :init.stop()
  end
end