jonnifyelixir
F7 · Deploy to Fly / F7.3 · the database / F7.3.2 · the empty-DB trap

F7.3 · deep dive 2 · the empty-DB trap & the fly proxy path

F7.3.2

A set URL is not a schema

On the first live bring-up the database portal existed on portal-postgres — and carried zero tables. A DATABASE_URL that resolves and authenticates is not a migrated schema: a database must be created and migrated, and those are two distinct steps. The second is the one the deploy work deferred. So before the app could boot well enough to migrate on a machine, the schema was applied another way — through a fly proxy that forwarded a local port onto the cluster, with Portal.Release.migrate/0 run locally against the proxy, landing on the writable primary. This dive walks that path and the two preconditions the docs gloss.

We walk the empty-DB trap (a set DATABASE_URL with no tables), the fly proxy path that applied the schema, the pg_is_in_recovery() writable-primary check, the CREATE role the migration needs, and the .internal-versus-.flycast host discriminator that is one more :nxdomain source.

Set versus created

F7.2 made the database host resolve: runtime.exs reads DATABASE_URL, Postgrex connects over Fly’s IPv6 network, and the connection authenticates against portal on portal-postgres. A connection that opens and authenticates is the trap: it proves the URL resolves, the credentials are accepted, and the named database exists — and proves nothing about the schema inside it. On the first bring-up that schema was empty. portal had been created, but no migration had ever run against it, so it carried zero tables.

Two steps hide inside “the database is ready.” First the database is created — the named database exists and a role can connect to it. Then it is migrated — the tables, indexes, and the schema_migrations bookkeeping row are applied. A DATABASE_URL that is merely set carries the first and says nothing of the second. The deploy work left the migrate step for the bring-up, which is why the first machine met a reachable database with no tables in it.

The inspector below contrasts the two states. Select DATABASE_URL set and a \dt in psql returns no relations — the connection works, the schema is empty. Select schema created and the same \dt lists courses, events, and schema_migrations, the state after the migration ran.

Set vs created · what \dt returns

portal=# \dt Did not find any relations. DATABASE_URL set

A connection that opens proves the URL resolves and the database exists; it proves nothing about the tables inside it — created and migrated are two distinct steps, and the empty-DB trap is the gap between them.

The fly proxy path

A built release carries no mix, and on the first bring-up the machine could not boot well enough to run a migration on its own: the engine reads the event log synchronously at startup, so an unmigrated database crashed the release before it could apply a schema (the boot-time coupling is the next dive). The schema had to be applied from outside the machine. The path the bring-up took opens a tunnel onto the cluster and runs the release-native migrator locally against it.

fly proxy 15432:5432 -a portal-postgres opens localhost:15432 and forwards it to portal-postgres.internal:5432 — the Postgres app’s port, reached over Fly’s private network. With the tunnel up, Portal.Release.migrate/0 runs on the operator’s own machine with DATABASE_URL repointed at the proxy — the URL takes the shape postgres://<user>:<pass>@localhost:15432/portal, where the host and port name the local end of the tunnel and the credentials are the cluster’s. The migrator is the same Portal.Release.migrate/0 a machine would run; only the connection is redirected through the forwarded port.

The on-machine alternative does exist once the release can boot: fly ssh console -a echo-portal -C '/app/bin/portal eval "Portal.Release.migrate()"' runs the same entry point inside the release on echo-portal. The proxy path is what applies the very first schema, before the machine can stay up long enough to run anything itself.

# open localhost:15432 → portal-postgres.internal:5432 over Fly's private network
fly proxy 15432:5432 -a portal-postgres

# run the release-native migrator locally, DATABASE_URL repointed at the proxy (shape only)
DATABASE_URL="postgres://<user>:<pass>@localhost:15432/portal" \
  mix eval "Portal.Release.migrate()"

# the on-machine alternative, once the release can boot:
fly ssh console -a echo-portal -C '/app/bin/portal eval "Portal.Release.migrate()"'

The proxy forwards a local port onto the cluster, and the same Portal.Release.migrate/0 a machine would run is run locally against it — redirecting the connection, not the migrator.

Two preconditions the docs gloss

The proxy path applies the schema only if two things hold, and neither is on the surface of the fly proxy documentation. The first is which node the proxy fronts. portal-postgres is an HA postgres-flex cluster: one writable primary and one or more read-only replicas. A migration is a write — CREATE TABLE and the row it writes into schema_migrations both mutate the database — so it must land on the primary. Run against a replica it fails, because a replica is read-only.

The check is one query. psql … -c "SELECT pg_is_in_recovery()" returns f on the writable primary and t on a replica (a replica is permanently in recovery, replaying the primary’s log). The proxy must front the node that answers f. The second precondition is the role: the migration needs CREATE on the database, since both CREATE TABLE and the schema_migrations bookkeeping require it — the bring-up used a superuser role, which carries it.

The dated record of the bring-up reads: the migration was run this way against the writable primary (pg_is_in_recovery() returned f, a superuser role), applying 20260604063825_create_courses then 20260604063826_create_events — after which courses, events, and schema_migrations were populated. Those two migrations create the schema the boot then reads: courses with a :bigint Snowflake primary key (create table(:courses, primary_key: false) plus add :id, :bigint, primary_key: true, autogenerate off), title/slug as null: false, a published boolean defaulting to false, timestamps(type: :utc_datetime), and a create unique_index(:courses, [:title]); and events — the append-only log behind Portal.EventStore.Postgres — with a :bigint Snowflake id, stream/type as null: false, a :integer seq, a :map jsonb data defaulting to %{}, timestamps(type: :utc_datetime, updated_at: false), and a create index(:events, [:stream, :seq]).

One host-scheme seam stays open as a roadmap detail. The .internal name the proxy uses is the direct-6PN, same-org host this umbrella reaches over; a Fly Managed Postgres cluster is reached over .flycast instead and needs fly mpg attach to wire it in. Choosing the wrong scheme is one more :nxdomain source — a name that does not resolve, not a database that is down. The check simulator below runs the writable-primary precondition: choose the node the proxy fronts and read pg_is_in_recovery() and whether the migration succeeds.

Writable-primary check · does this node take the migration?

localhost:15432 fly proxy .internal portal-postgres · postgres-flex (HA) primary · writable pg_is_in_recovery() = f MIGRATES HERE replica · read-only pg_is_in_recovery() = t

A migration is a write, so it lands on the node that answers pg_is_in_recovery() with f: the writable primary takes CREATE TABLE, the read-only replica rejects it.

the trap

a DATABASE_URL that resolves and authenticates against portal on portal-postgres — with zero tables inside. Set is not created; created is not migrated.

the path out

fly proxy forwards a local port onto the cluster, and Portal.Release.migrate/0 runs locally against the writable primary (pg_is_in_recovery() = f) with a CREATE role.

The schema now exists: courses, events, and schema_migrations are populated, and Portal.Repo reads a migrated database. But applying the schema once does not change why the machine could not boot to migrate itself — the boot reads the event log synchronously, so a reachable-but-unmigrated database crashes the release. That coupling is the next dive, F7.3.3 · the boot-time DB coupling; the release-native migrator it triggers is F7.3.1.

References

The fly proxy forwarding that opened the tunnel, the HA cluster that has one writable primary, the pg_is_in_recovery() check that tells them apart, and the migration DSL behind the two files the bring-up applied.

Sources

Related in this course