F7.3 · deep dive 1 · release-native migration
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
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
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
- Phoenix — Deploying with releases — the
Releasemodule pattern and running migrations throughevalbecause a release carries nomix. - Ecto — Ecto.Migrator —
with_repo/2,run/4, and theschema_migrationsbookkeeping that makes a re-run idempotent. - Mix — mix release — what a built release contains (and does not): the
bin/<app>script and beams, no Mix. - Elixir — Application —
load/1andfetch_env!/2, howmigrate/0reaches the repo config without starting the tree.
Related in this course
- F7.3 · the database goes live — the module hub this dive opens.
- F7.3.2 · the empty-DB trap & the fly proxy path — why an empty database is not a working one, and how the first bring-up reached the writable primary.
- F7.3.3 · the boot-time DB coupling — the synchronous read at boot whose hardening is named, not built.
- F7 · Deploy to Fly — the chapter landing this module belongs to.