jonnifyelixir
F7 · Deploy to Fly / F7.3 · the database / F7.3.1 · release-native migration

F7.3 · deep dive 1 · release-native migration

F7.3.1

A release with no mix

A built release ships the compiled BEAM .beam files and a bin/portal script — not the Mix archive. So on the deploy machine mix ecto.migrate is not a command that exists, and the schema cannot be applied the way it is in development. Portal.Release fills that gap. Its migrate/0 loads the application without booting the supervision tree, starts the repo in isolation, runs every pending migration, and stops — invoked release-native with bin/portal eval "Portal.Release.migrate()". The same module’s rollback/2 walks the reverse, and the moduledoc fixes the deploy order: migrate runs before the endpoint accepts traffic.

We read echo/apps/portal/lib/portal/release.ex as built — the migrate/0 call chain (load_app/0 → repos/0 → Ecto.Migrator.with_repo/2 → Ecto.Migrator.run/4), the rollback/2 reverse, the migrate-before-start contract, why a re-run is idempotent, and the two triggers that invoke the same entry point.

A release carries no mix

In development the schema is applied with mix ecto.migrate, a Mix task. A built release is a different artifact: mix release assembles the compiled .beam files and a generated bin/portal launcher into an image, and it does not bundle the Mix build tool. On the deploy machine there is no mix to run, so mix ecto.migrate is unavailable — the development path to the schema is gone.

Portal.Release is the ops entry point that replaces it. The release launcher carries an eval verb that evaluates an Elixir expression inside the release, so a migration is invoked as bin/portal eval "Portal.Release.migrate()" — or, reaching the machine over SSH, fly ssh console -a echo-portal -C '/app/bin/portal eval "Portal.Release.migrate()"'. Three names sit in that one command and must not be conflated: echo-portal is the Fly app, portal is the OTP release (hence bin/portal), and the database itself runs as a separate app, portal-postgres.

Select what the deploy machine carries below and read which path to the schema is open on it.

What the deploy machine carries · and how the schema is applied

ARTIFACT · mix project the Mix archive (mix, mix.exs) source + deps mix ecto.migrate works ARTIFACT · built release compiled .beam files bin/portal launcher · NO mix bin/portal eval Portal.Release.migrate()

A built release has no Mix archive, so the schema is not applied with mix ecto.migrate but through Portal.Release.migrate/0, evaluated inside the release by bin/portal eval.

Inside migrate/0

Portal.Release.migrate/0 does four things in order. First it calls load_app/0, which is Application.load(:portal) — this makes the repo configuration and the bundled migrations available without booting the full supervision tree. No Engine starts, no endpoint binds; only the application’s config and code are loaded. Then it iterates repos/0, which reads Application.fetch_env!(:portal, :ecto_repos). That value is [Portal.Repo], set by config :portal, ecto_repos: [Portal.Repo] in config/config.exs — so the list a release migrates is exactly the list of repos the app declares.

For each repo it runs Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)). with_repo/2 starts the repo in isolation — only the database connection, not the rest of the tree — hands it to the given function, and stops it afterward. The function Ecto.Migrator.run(&1, :up, all: true) applies every pending migration in version order. The whole thing runs as a short-lived task: load, start the repo alone, migrate, stop.

The moduledoc fixes where this sits in a deploy. The sequence runs migrate/0 before bin/portal start, so the schema is current before the endpoint accepts traffic. The reverse direction is rollback/2: it loads the app the same way, then runs Ecto.Migrator.run(&1, :down, to: version) to walk back to a named version — invoked bin/portal eval "Portal.Release.rollback(Portal.Repo, <version>)".

# echo/apps/portal/lib/portal/release.ex — the release-native migration task
defmodule Portal.Release do
  # a built release carries no mix; bin/portal eval calls this instead of mix ecto.migrate
  @app :portal

  def migrate do
    load_app()                                       # Application.load(:portal) — config + migrations, no tree

    for repo <- repos() do                          # repos() = [Portal.Repo] from :ecto_repos
      {:ok, _, _} =
        Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end                                                # with_repo starts the repo alone, runs, stops it

    :ok
  end

  def rollback(repo, version) do             # bin/portal eval "Portal.Release.rollback(Portal.Repo, <version>)"
    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)
  defp load_app, do: Application.load(@app)
end

migrate/0 loads the app without its tree, then for each declared repo with_repo starts the connection alone, run(…, :up, all: true) applies every pending migration, and the connection stops — a short task that finishes before the endpoint starts.

Idempotent on every deploy

Running a migration twice is safe, and the reason is bookkeeping that Ecto.Migrator keeps for you. Each migration carries a numeric version, and once it is applied that version is recorded in a schema_migrations table. When Ecto.Migrator.run(&1, :up, all: true) runs, it consults that table and applies only the versions not already recorded — a version that is present is skipped, not re-run.

This repo ships two migrations, in version order: 20260604063825 (create_courses) and 20260604063826 (create_events). On a fresh database neither is recorded, so both apply. After a partial deploy — say the first migration committed and the run was interrupted — only the second is still pending, so a re-run applies that one alone. On a database where both are recorded a re-run applies nothing. That is what makes migrate/0 safe to call on every deploy: it converges the schema to current and does no work that is already done.

Choose the starting schema_migrations state below and read which versions a run applies.

Idempotency · what Ecto.Migrator runs from each starting state

Ecto.Migrator.run(&1, :up, all: true) — only versions NOT in schema_migrations 20260604063825 create_courses APPLIES 20260604063826 create_events APPLIES

Because Ecto.Migrator consults schema_migrations and applies only what is not yet recorded, a re-run after a partial or repeated deploy applies nothing already present — migrate/0 is safe to run on every deploy.

the manual trigger

the first bring-up ran bin/portal eval "Portal.Release.migrate()" by hand over fly ssh console — one explicit invocation against the deploy machine.

the automatic trigger

a fly.toml [deploy] release_command runs the migration in a temporary VM before each rolling release; wiring it is the Operator’s call — either way the entry point is the same migrate/0.

The entry point is settled: a release with no mix migrates through Portal.Release.migrate/0, idempotently, before the endpoint starts. But a migration that applies cleanly still leaves a question — an empty database is not a working one. The next dive, F7.3.2 · the empty-DB trap & the fly proxy path, follows how the first bring-up reached the writable primary; then F7.3.3 names the boot-time DB coupling this all guards against.

References

How a built release runs its migrations through an eval entry point because it carries no mix, what Ecto.Migrator does and why a re-run is idempotent, and how migrate/0 reaches the repo config without starting the supervision tree.

Sources

Related in this course