Migrations with Elixir Releases

Since I’m using releases for the production environment, I won’t have the Mix tool to run migrations. I didn’t have Ecto included with my original Phoenix project so the files generated for releases did not include anything for running Ecto migrations from a release. I generate these missing migration files by running mix phx.gen.release again which adds the Minotaur.Release module and a migrate script.

# lib/minotaur/release.ex

defmodule Minotaur.Release do
  @moduledoc """
  Used for executing DB release tasks when run in production without Mix
  installed.
  """
  @app :minotaur

  def migrate do
    load_app()

    for repo <- 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 repos do
    Application.fetch_env!(@app, :ecto_repos)
  end

  defp load_app do
    Application.load(@app)
  end
end
# rel/overlays/bin/migrate
#!/bin/sh
set -eu

cd -P -- "$(dirname -- "$0")"
exec ./minotaur eval Minotaur.Release.migrate

I commit these files to the project repository and pull the changes from my production server. I create a new Docker image and test the migration script by running a container with an entrypoint override.

$ docker run --entrypoint /app/bin/migrate -e DATABASE_URL=$MINOTAUR_DATABASE_URL -e SECRET_KEY_BASE=$SECRET_KEY_BASE --add-host=host.docker.internal:host-gateway minotaur:ecto
[info] Migrations already up

The migrate script ran successfully. There are no migrations to run yet, so the resulting log message is expected. Now that I know I can run migrations in production, I will create some migration files to test the database user has all the necessary permissions.

Writing migrations

From my local development environment, I generate a new migration file in my project by running mix ecto.gen.migration add_users_table which creates priv/repo/migrations/20240705205730_add_users_table.exs with the following content:

defmodule Minotaur.Repo.Migrations.AddUsersTable do
  use Ecto.Migration

  def change do

  end
end

I update the change function to create a new table with a few fields and a unique index constraint. This migration is just for testing permissions so I don’t care about the table or field names at this point.

def change do
  create table(:users) do
    add :first_name, :string
    add :last_name, :string
    add :email, :string

    timestamps()
  end

  create unique_index(:users, [:email])
end

I run the migration locally with mix ecto.migrate which successfully creates the table and index in the local development database.

I also create a User module to test the insert functionality.

defmodule User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :first_name, :string
    field :last_name, :string
    field :email, :string

    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:first_name, :last_name, :email])
    |> validate_required([:first_name, :last_name, :email])
    |> unique_constraint(:email)
  end
end

I start a REPL with iex -S mix phx.server, create a test user record, and delete it.

iex(1)> attrs = %{first_name: "John", last_name: "Doe", email: "[email protected]"}
%{first_name: "John", last_name: "Doe", email: "[email protected]"}

iex(2)> user = User.changeset(%User{}, attrs)
#Ecto.Changeset<
  action: nil,
  changes: %{first_name: "John", last_name: "Doe", email: "[email protected]"},
  errors: [],
  data: #User<>,
  valid?: true
>

iex(3)> Minotaur.Repo.insert(user)
{:ok,
 %User{
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   id: 1,
   first_name: "John",
   last_name: "Doe",
   email: "[email protected]",
   inserted_at: ~N[2024-07-05 21:23:53],
   updated_at: ~N[2024-07-05 21:23:53]
 }}

iex(4)> users = Minotaur.Repo.all(User)
[
  %User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 1,
    first_name: "John",
    last_name: "Doe",
    email: "[email protected]",
    inserted_at: ~N[2024-07-05 21:23:53],
    updated_at: ~N[2024-07-05 21:23:53]
  }
]

iex(5)> Minotaur.Repo.delete(List.first(users))
{:ok,
 %User{
   __meta__: #Ecto.Schema.Metadata<:deleted, "users">,
   id: 1,
   first_name: "John",
   last_name: "Doe",
   email: "[email protected]",
   inserted_at: ~N[2024-07-05 21:23:53],
   updated_at: ~N[2024-07-05 21:23:53]
 }}

iex(6)> Minotaur.Repo.all(User)
[]

Everything seems to be working correctly in the development environment.

I commit these changes to a new branch and check them out on my production server. I build a new image tagged as minotaur:tmp-migration and run the following command to apply the migration:

$ docker run --entrypoint /app/bin/migrate -e DATABASE_URL=$MINOTAUR_DATABASE_URL -e SECRET_KEY_BASE=$SECRET_KEY_BASE --add-host=host.docker.internal:host-gateway minotaur:tmp-migration
22:03:26.122 [info] == Running 20240705205730 Minotaur.Repo.Migrations.AddUsersTable.change/0 forward
22:03:26.128 [info] create table users
22:03:26.167 [info] create index users_email_index
22:03:26.178 [info] == Migrated 20240705205730 in 0.0s

The migration looks good so I’ll now start a REPL with a production node to test the database user permissions. I start the container with an interactive IEx terminal.

$ docker run --entrypoint /app/bin/minotaur -e DATABASE_URL=$MINOTAUR_DATABASE_URL -e SECRET_KEY_BASE=$SECRET_KEY_BASE --add-host=host.docker.internal:host-gateway -it minotaur:tmp-migration start_iex
22:17:51.696 [info] Starting Horde.RegistryImpl with name Minotaur.GameEngine.SessionRegistry
22:17:51.697 [info] Starting Horde.DynamicSupervisorImpl with name Minotaur.GameEngine.SessionSupervisor
22:17:51.703 [info] Configuration :server was not enabled for MinotaurWeb.Endpoint, http/https services won't start
Erlang/OTP 26 [erts-14.2.5] [source] [64-bit] [smp:1:1] [ds:1:1:10] [async-threads:1] [jit:ns]

Interactive Elixir (1.16.2) - press Ctrl+C to exit (type h() ENTER for help)
iex([email protected])1>

I then run the same commands as with the development environment to make sure the CRUD permissions are working. I see the same results where the user record is created and deleted successfully.

I create two new migration files to test table permissions. The first migration alters the users table by dropping the last_name column. The second migration drops the users table completely.

defmodule Minotaur.Repo.Migrations.DeleteUsersLastName do
  use Ecto.Migration

  def change do
    alter table(:users) do
      remove :last_name
    end
  end
end
defmodule Minotaur.Repo.Migrations.DropUsersTable do
  use Ecto.Migration

  def change do
    drop table(:users)
  end
end

The migrations run successfully on the local development database so I push these changes to the same branch as the previous migration changes and pull the changes from the production server. I rebuild the docker image and run the migration script again.

$ docker run --entrypoint /app/bin/migrate -e DATABASE_URL=$MINOTAUR_DATABASE_URL -e SECRET_KEY_BASE=$SECRET_KEY_BASE --add-host=host.docker.internal:host-gateway minotaur:tmp-migration
22:47:56.614 [info] == Running 20240705223300 Minotaur.Repo.Migrations.DeleteUsersLastName.change/0 forward
22:47:56.619 [info] alter table users
22:47:56.629 [info] == Migrated 20240705223300 in 0.0s
22:47:56.706 [info] == Running 20240705224038 Minotaur.Repo.Migrations.DropUsersTable.change/0 forward
22:47:56.707 [info] drop table users
22:47:56.712 [info] == Migrated 20240705224038 in 0.0s

The permissions look good for altering and dropping tables in production. Next, I want to automate running migrations with deployments.

Automating migrations at startup

It would be very convenient to have any outstanding migrations automatically run before booting the application on production deploys. Ecto migrations lock the schema_migrations table by default so there isn’t a concern about automatically running migrations during startup even when there are multiple instances starting at once.

A simple change to the Dockerfile is all it takes to implement this behavior:

- CMD ["/app/bin/server"]
+ CMD /app/bin/migrate && /app/bin/server

I rebuild the image and deploy the change to the Swarm service with docker service update --image minotaur:auto-migrate. The service logs show the expected Migrations already up message before the application boot up messages.

And that’s it for Ecto setup!

I think I’m finished tinkering with operations tasks for a bit. A couple items I’ve left on my to-do list are automating deploys with a pipeline runner and to replace the hard-coded IP matching in the release environment script with a build-time argument. I’ll leave those be for now since they aren’t urgent and I’m eager to get back to writing Elixir code.