The more we work on an application, the more changes we introduce. Code changes are easily managed with version control, while database schema changes are handled through database migrations.

If you've been deploying your Ash application using releases, you probably want to run migrations in production without having to redeploy the entire application.

The challenge is that Elixir releases do not include Mix. As a result, you cannot run mix ash.migrate directly in production.

You need a way to execute migrations without depending on Mix. And that's exactly what I'm going to show you.

Let's walk through how to do it (assuming you're using PostgreSQL).

This approach is also documented in the ash_postgres package but it is not automatically generated for you, so you can find the official reference there as well.

Here's how to do it:

  1. Create lib/my_app/release.ex file
  2. Add below content and replace :my_app with the name of you appliation.
defmodule MyApp.Release do
  @moduledoc """
Tasks that need to be executed in the released application (because mix is not present in releases).
  """
  @app :my_app # <-- Replace this with the name of you application
  def migrate do
    load_app()

    for repo <- repos() do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end

  # only needed if you are using postgres multitenancy
  def migrate_tenants do
    load_app()

    for repo <- repos() do
      path = Ecto.Migrator.migrations_path(repo, "tenant_migrations")
      # This may be different for you if you are not using the default tenant migrations

      {:ok, _, _} =
        Ecto.Migrator.with_repo(
          repo,
          fn repo ->
            for tenant <- repo.all_tenants() do
              Ecto.Migrator.run(repo, path, :up, all: true, prefix: tenant)
            end
          end
        )
    end
  end

  # only needed if you are using postgres multitenancy
  def migrate_all do
    load_app()
    migrate()
    migrate_tenants()
  end

  def rollback(repo, version) do
    load_app()
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
  end

  # only needed if you are using postgres multitenancy
  def rollback_tenants(repo, version) do
    load_app()

    path = Ecto.Migrator.migrations_path(repo, "tenant_migrations")
    # This may be different for you if you are not using the default tenant migrations

    for tenant <- repo.all_tenants() do
      {:ok, _, _} =
        Ecto.Migrator.with_repo(
          repo,
          &Ecto.Migrator.run(&1, path, :down,
            to: version,
            prefix: tenant
          )
        )
    end
  end

  defp repos do
    domains()
    |> Enum.flat_map(fn domain ->
      domain
      |> Ash.Domain.Info.resources()
      |> Enum.map(&AshPostgres.DataLayer.Info.repo/1)
      |> Enum.reject(&is_nil/1)
    end)
    |> Enum.uniq()
  end

  defp domains do
    Application.fetch_env!(@app, :ash_domains)
  end

  defp load_app do
    Application.load(@app)
  end
end

3. Then you can run migrations in production release like the following:

  • Basic Migrations (non-tenant): Run all pending migrations upward.
_build/prod/rel/my_app/bin/my_app eval "MyApp.Release.migrate()"
  • Tenant Migrations: If using multitenancy, run tenant-specific migrations.
_build/prod/rel/my_app/bin/my_app eval "MyApp.Release.migrate_tenants()"
  • All Migrations (tenant + non-tenant): Run everything.
_build/prod/rel/my_app/bin/my_app eval "MyApp.Release.migrate_all()"
  • Rollback: Roll back to a specific version (replace MyApp.Repo with your repo module and 1234567890 with the migration timestamp).
_build/prod/rel/my_app/bin/my_app eval "MyApp.Release.rollback(MyApp.Repo, 1234567890)"

Since your application is already running, you can also expose this functionality on a settings or admin page. This way, you (or an authorized admin) can run migrations or roll back with just clicks with no SSH, no custom scripts, and no redeployment required.

Let me know in the comments how you've been handling migrations in production so far! I'm curious to hear about your setup.