jonnifyelixir
F7 · Deploy to Fly / F7.5 · Portal is live / F7.5.3 · day-2 operations

F7.5 · deep dive 3 · keep it live after the first boot

F7.5.3

Keep it live after the first boot

The first bring-up did not only stand the Portal up; it forced a small set of day-2 operations into existence — the ones an operator reaches for once the endpoint is serving and a question comes up. Each is a single command whose output is the fact: fly logs answers what the machine printed at boot, fly status answers whether it is started and on which release, fly proxy + psql answer what the production database actually holds, and Portal.Release.migrate/0 answers whether any migration is still pending. One rule sits over all of them: no command prints a secret — DATABASE_URL and SECRET_KEY_BASE are referenced by name and provisioned by fly secrets, and the psql inspection queries the catalog and schema_migrations, never the credential.

We work the day-2 surface as the 2026-06-06 bring-up left it: fly logs and the stopped-machine trap, fly machine status / fly status / fly releases, fly proxy + psql over the prod DB, the idempotency of a re-run Portal.Release.migrate/0, Portal.Release.rollback/2 as the reversal path, and redeploy/restart as the recovery move.

Read the boot, and read the state

Day-2 starts with two questions an operator asks against the Fly app echo-portal: what did the machine print, and is it started on the release I expect. The first is a log read. fly logs -a echo-portal -i <machine> tails one machine’s output, and the boot lines are where the chapter’s four gotchas surface — the inetrc miss, the two identical :nxdomain faults, the engine child that exits and suspends. The trap is that a stopped machine has no live tail: reading a dead machine’s stream returns an empty stream, not the boot you wanted. To capture a fresh boot, restart the machine first — fly machine restart <id> -a echo-portal — and read the lines it prints coming up, rather than staring at a stopped machine that is printing nothing.

The second question is answered by status. fly machine status <id> -a echo-portal reports one machine’s state, fly status reports the app’s, and fly releases -a echo-portal reports which release version is current. Together they answer “is it started, and which release is running” — the two inputs the F7.5.2 triage tree branches on before it maps a boot symptom to its owning gotcha. The OTP release is portal; the Fly app is echo-portal; the database app is portal-postgres — three names that name three different things, kept distinct.

Select a day-2 operation below to read the command it runs and the fact its output supplies.

The day-2 surface · the command, and the fact its output gives

OPERATION logs COMMAND fly logs -a echo-portal -i <machine> FACT · the machine’s boot lines — restart first for a fresh boot

Each day-2 operation is one command whose output is the fact — the boot lines, the started-and-current state, the catalog rows, the pending-migration count — and none of them prints a secret.

Inspect the prod DB over a proxy

When the question is about the data, an operator inspects the production database directly — without exposing it to the public internet. fly proxy 15432:5432 -a portal-postgres opens a local tunnel: localhost:15432 forwards to portal-postgres.internal:5432 over Fly’s private network. With the proxy up, a local psql connects through it — the connection string is a shape like postgres://<user>@localhost:15432/portal, with no password written anywhere; the credential lives in fly secrets, referenced by name. The proxy is the seam: the DB never listens on a public port, and the inspection runs over a tunnel the operator opened and will close.

Two production facts from the bring-up bound the inspection. First, the connection must land on the writable primary, not a high-availability replica. SELECT pg_is_in_recovery(); answers in one boolean: f on the primary, which accepts writes, and t on a replica, which is read-only and in recovery. A write run against a t node fails for a reason that has nothing to do with the query — so the first thing to read is which node the proxy reached.

Second, a client/server psql version skew can break the \l meta-command (it issues a query the server’s catalog version does not answer the same way). The fix is to stop relying on the meta-command and query the catalog directly: SELECT datname FROM pg_database; lists the databases (the \l workaround), and SELECT version FROM schema_migrations; lists the applied migration versions. The catalog and schema_migrations are plain tables; querying them never touches a credential.

Select a check below to read its exact psql query and the result it returns over the proxy.

Inspecting the prod DB over a proxy · the query, and the result

portal=# SELECT pg_is_in_recovery(); pg_is_in_recovery ------------------- f (1 row)

The proxy keeps the DB private, the recovery check confirms the writable primary, and a catalog-direct query reads the databases and applied migrations past a \l skew — all without a credential ever appearing.

Re-migrate, roll back, recover

The migration path is the same release task the deploy ran, only now run by hand. Portal.Release.migrate/0 (in Portal.Release) loads the :portal app, starts Portal.Repo in isolation through Ecto.Migrator.with_repo/2, and runs Ecto.Migrator.run(&1, :up, all: true) — which applies only the pending migrations. That makes a re-run idempotent: against a schema that is already current it applies nothing, so re-running it after a deploy is a no-op. The 2026-06-06 run applied 20260604063825_create_courses and 20260604063826_create_events; with both already recorded in schema_migrations, a second migrate/0 adds nothing.

The reversal path is its sibling. Portal.Release.rollback/2 (in Portal.Release) runs Ecto.Migrator.run(&1, :down, to: version), run release-native as bin/portal eval "Portal.Release.rollback(Portal.Repo, <version>)". It rolls Portal.Repo back to and including the named version — the day-2 move when a migration has to be undone. Both tasks name only Portal.Repo and Portal.Release; neither reaches under the Portal facade, and neither prints a secret.

Above the migration tasks sits the recovery move. When a fault is transient — a stuck process, a machine wedged after a network blip — the first move is to redeploy (fly deploy rebuilds the image and rolls the machines) or to restart (fly machine restart brings one machine back up). When the fault is structural — a boot symptom that recurs on every restart — the move is not to restart again but to open the F7.5.2 triage tree and route the symptom to the gotcha that owns it.

# Portal.Release — the release-native migration tasks (run on the deploy machine)
# re-migration is idempotent: run/4 :up applies only the PENDING migrations
$ bin/portal eval "Portal.Release.migrate()"
  # first run (2026-06-06): applied 20260604063825_create_courses + 20260604063826_create_events
  # re-run against a current schema: NO-OP — nothing pending, nothing applied

# the reversal path: roll Portal.Repo back to (and including) a version
$ bin/portal eval "Portal.Release.rollback(Portal.Repo, 20260604063826)"
  # runs Ecto.Migrator.run(&1, :down, to: version)

# no secret is printed: DATABASE_URL / SECRET_KEY_BASE are referenced by name,
# provisioned by `fly secrets`; the psql inspection queries the catalog only.

Transient vs structural · the first recovery move

TRANSIENT FAULT fly deploy · fly machine restart rebuild & roll, or bring the machine back up STRUCTURAL FAULT open the F7.5.2 triage tree route the boot symptom to its owning gotcha

A redeploy or a restart is the first move for a transient fault; a recurring boot symptom is structural, and its move is the F7.5.2 triage tree, not another restart.

the question

what did it print at boot, is it started on the right release, what does the prod DB hold, is a migration pending, and how do I undo one — the questions day-2 actually asks.

the answer

one command whose output is the fact — fly logs/status/releases, fly proxy + psql, Portal.Release.migrate/0 and rollback/2 — none of them printing a secret.

This is the last dive of the last module of the chapter. F7.1 built the image, F7.2 resolved the runtime config, F7.3 migrated the database, F7.4 ran the deploy, and F7.5 proved the composition is live (F7.5.1), triaged its faults (F7.5.2), and — here — keeps it live after the first boot. There is nothing after the capstone: return to the module hub.

References

The boot output a stopped machine cannot tail, the fly proxy + psql path that inspects the prod DB directly, the recovery-state function that distinguishes a writable primary from a replica, and the idempotent migrator behind Portal.Release.

Sources

Related in this course