Run Ecto Migrations in Production with Distillery's Boot Hooks

You need to run you're migrations with the production release of your Elixir app, in your production environment. You can't use mix! You can use Ecto Migrator. Read on to find out how to run your Ecto migrations in production using Distillery's Boot Hooks.

The Problem

Coming from a Rails background, I just sort of expected to be able to execute my migrations in the production environment exactly like I would in dev.

mix ecto.migrate

I was confused when my attempts to do so in production were met with:

** (Mix) The task "ecto.migrate" could not be found

Turns out, mix tasks aren't compiled into the project BEAMs, and therefore they aren't deployed with the release. Womp womp.

Lucky for us, we can leverage the easy-to-use Ecto Migrator API to define a custom migration task. Then, we can run this task with Distillery's Boot Hooks.

A Note on Distillery

This problem (no mix in prod) and the solution of using Ecto Migrator was revealed to me by this excellent post by Flatiron Labs engineer Kate Travers. Sadly for me, Kate's project used eDeliver for deployments, which made it even easier to leverage Ecto Migrator. I had to take a slightly different approach to get my migration script working with Distillery.

Writing a Custom Migration Task with Ecto Migrator

Our custom migration task is pretty simple. We'll define a module MyApp.ReleaseTasks that looks like this:

defmodule MyApp.ReleaseTasks do
  def migrate do
    {:ok, _} = Application.ensure_all_started(:my_app)

    path = Application.app_dir(:my_app, "priv/repo/migrations")

    Ecto.Migrator.run(MyApp.Repo, path, :up, all: true)
  end
end

Our task does a few things:

  • Make sure the app is started
  • Grab the path to the migrations directory
  • Use Ecto.Migrator to run the migrations against our app's Repo.

Now that we have a nice tidy function that wraps up our Ecto.Migrator work, let's write a shell script that Distillery can execute for us when it starts running a production release.

Writing a Migration Shell Script

Leveraging Erlang's rpc Module

In order to execute our ReleaseTasks.migration/0 function against our production release, we'll use Erlang's rpc module. The rpc module provides Remote Produce Call services

A remote procedure call is a method to call a function on a remote node and collect the answer. It is used for collecting information on a remote node, or for running a function with some specific side effects on the remote node.

rpc calls are executed like this:

:rpc.call(node, module, fun, args)

We can send an rpc call to our production release like this:

bin/my_app rpc "Elixir.MyApp.ReleaseTasks.migrate"

Now that we know how to call on our task, let's write that shell script!

The Migration Shell Script

set +e

echo "Preparing to run migrations"

while true; do
  nodetool ping
  EXIT_CODE=$?
  if [ $EXIT_CODE -eq 0 ]; then
    echo "Application is up!"
    break
  fi
done

set -e

echo "Running migrations"
bin/my_app rpc "Elixir.MyApp.ReleaseTasks.migrate"
echo "Migrations run successfully"

Our script is pretty simple, it loops until our application is up and running (with the help of the nodetool command line utility). Then, once the app is up, it uses rpc to execute our migration function.

Now that our shell script is ready to go, we need to teach Distillery when to execute it.

Executing the Migration Script with Distillery's Boot Hooks

Distillery allows us to execute shell scripts at points in time via Boot Hooks.

Boot hooks are scripts which execute pre/post to events that occur in the run control script.

Whatever scripts we specify as a pre/post hook will get sourced into Distillery's own boot script at runtime.

We want our migration task to run as a post_start script, since it requires the app to be running.

In order to leverage boot hooks, we need to do two things:

  • Tell Distillery that we have a post start script for it to run
  • Place the post start script in the correct file path
# rel/config.exs

use Mix.Releases.Config,
    default_release: :default,
    default_environment: Mix.env()
...
environment :prod do
  set include_erts: true
  set include_src: false
  set post_start_hooks: "rel/hooks/post_start"
end
...

This will tell Distilerry to execute any scripts in the rel/hooks/post_start directory after the app starts up. So, we need to place our migration script in a file:

rel/hooks/post_start/migrate.sh

And that's it! When we build our production release and run it, our migrations will run.

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus