jonnifyelixir
F7 · Deploy to Fly / F7.4 · fly.toml & the deploy mechanics / F7.4.1 · the distilled fly.toml

F7.4 · deep dive 1 · the distilled fly.toml

F7.4.1

Distilled to the essentials

A full Codemoji production fly.toml carried Fastify workers, a Redis queue, a SQLitestream replicator, a Datadog agent, and a game database. This one carries one Phoenix release and one Postgres. The file in echo/fly.toml is that template distilled down to the Portal essentials — its own header comment records the distillation, and records that the OTP release portal (the binary is bin/portal) and the Fly app echo-portal are independent names that must never be conflated. Reading a config is as much about what it dropped as what it kept: every dropped family is a service this single release does not run, and every kept stanza declares one fact about how Fly should place, start, and check the machine.

We walk echo/fly.toml top to bottom — the kept stanzas ([build] with its pinned Elixir/OTP/Debian args, [deploy], [env] with no secret in it, [experimental], [[vm]], [[services]], and [[services.http_checks]]) — then the de-Codemoji drop list, and finally the coupling that makes the health check honest: path = "/health" must equal the as-built F6.1 route.

Walking the stanzas, top to bottom

The file opens with four top-level keys: app = "echo-portal" names the Fly application, primary_region = "fra" places it in Frankfurt, and the kill_signal = "SIGTERM" / kill_timeout = "30s" pair tells Fly to send a graceful SIGTERM on shutdown and wait thirty seconds before a hard stop — long enough for the BEAM to drain in-flight requests. app is the Fly side of the two-name split: the OTP release inside the image is still portal.

Below the top level come the stanzas. [build] sets dockerfile = "Dockerfile" and pins three build args — ELIXIR_VERSION = "1.18.4", OTP_VERSION = "28.5.0.1", and DEBIAN_DATE = "20260518" — a fully reproducible base image, with no NODE_VERSION because there is no Node build. [deploy] sets strategy = "rolling"; note that there is no release_command here, which the migration-on-deploy dive (F7.4.3) takes up. [env] carries the non-secret runtime environment — PHX_HOST, PHX_SERVER, MIX_ENV, PORT, POOL_SIZE, the IPv6 trio (ERL_INETRC, ECTO_IPV6, ERL_AFLAGS), and TZ/LANG — and carries no secret, which F7.4.2 covers. [experimental] sets auto_rollback = true; [[vm]] declares the machine size, and [[services]] with its nested [[services.http_checks]] declares the network surface and how Fly decides a machine is healthy.

Select a stanza below to light it in the outline and read what it declares.

fly.toml outline · what each stanza declares

app = "echo-portal" · primary_region = "fra" [build] dockerfile + pinned ELIXIR/OTP/DEBIAN args [deploy] strategy = "rolling" [experimental] auto_rollback = true [[vm]] shared · cpus = 2 · memory_mb = 1024 [[services]] internal_port = 4000 · ports 80→443 [[services.http_checks]] GET /health every 30s

Each kept stanza declares exactly one fact about the deploy — where to place it, how to roll it, how big the machine is, what ports it answers on, and how the proxy decides it is healthy.

What the distillation dropped

The header comment is explicit that this file was distilled from docs/deploy/fly.toml, the full Codemoji production config, “to the Portal essentials: a single Phoenix release + Postgres.” The drop list is the F6.8.2-D8 decision made concrete. A distilled config is easiest to read when you know what is not there, so each removed family is worth naming once: the [[vm]] went from the template’s 2048 MB down to 1024 precisely because there are no longer four other services sharing the machine.

Gone are all the Fastify worker settings (WORKER_*, FASTIFY_PORT) and the queue env (QUEUE_*, the NODE_* family) — Portal is one BEAM release, not a Node worker fleet. Gone is Redis and BullMQ, because the queue runs in-process, not on a Redis broker. Gone is the SQLitestream replicator and its watch env (DISTR_WATCH_*, VOLUME_WATCH_*), because Postgres is the store of record. Gone are all the Datadog agent variables (DD_*) and the GAME_DB_NAME game database, and the DEV_LIVE/LOG_LEVEL development env, along with the Codemoji header comments themselves. Select a dropped family below to read what it was in the template and why this release omits it.

The drop list · one Phoenix release runs none of these

Fastify / WORKER_* was: WORKER_* · FASTIFY_PORT why: Portal is one BEAM release, not a Node worker fleet. REMOVED

A distilled config omits whole families on purpose: each dropped block is a service this single Phoenix release does not run, so its env has no reason to exist in the file.

the Codemoji template

one machine sharing a Fastify worker fleet, a Redis/BullMQ queue, a SQLitestream replicator, a Datadog agent, and a game database — the [[vm]] sized at 2048 MB to hold them all.

the distilled Portal

one Phoenix release plus one Postgres, the [[vm]] distilled to 1024 MB, and an [env] that carries no WORKER_*, DD_*, or NODE_* at all.

The coupling that makes the health check honest

The last stanza is the one with a dependency outside the file. [[services.http_checks]] declares a GET /health probe on a 30s interval, with the header X-Forwarded-Proto = "https" so the app sees the same scheme it would behind the edge. Its path = "/health" is not free to choose: it must equal the route the application actually serves. The as-built F6.1 route lives in apps/portal_web/lib/portal_web/router.ex as get("/health", PortalWeb.CourseController, :health). The two strings — the check path and the router path — must agree for “healthy” to mean what it says: a machine the proxy can reach and whose app answers.

This is the F7.4-INV2 coupling. If either side drifts — the check is pointed at /healthz while the router still serves /health, or the route is renamed while the check is left behind — the probe fails on a perfectly healthy machine, and Fly marks it unhealthy and will not route traffic to it. Nothing in fly.toml can detect the mismatch, because the route it names lives in a different file in a different app; the agreement is held by convention, so it is worth stating once and keeping. The whole rest of the [[services]] stanza supports this same goal: the 80→443 redirect (force_https = true) and the 443 TLS port with alpn = ["h2", "http/1.1"] put the endpoint on HTTPS, and auto_stop_machines = false with min_machines_running = 1 keep at least one node warm so the cluster and Presence counts stay warm and a node is not reaped mid-session.

# echo/fly.toml — the http_checks stanza (the path is the coupling)
[[services.http_checks]]
  interval = "30s"
  method   = "get"
  path     = "/health"            # MUST equal the as-built F6.1 router path
  protocol = "http"
  [services.http_checks.headers]
    X-Forwarded-Proto = "https"

# apps/portal_web/lib/portal_web/router.ex — the route the check reaches
get("/health", PortalWeb.CourseController, :health)

A health check is only honest when its path equals a route the app really serves — and because the two live in different files, the agreement is held by convention, not by the compiler.

The stanzas are now walked and the drop list named. The one stanza we deferred — [env] carries no secret — is the next dive’s subject: F7.4.2 · secrets & env provisioning shows where the two secrets are set and the raise contract that catches a missing one; then F7.4.3 takes up the absent release_command and what a rolling deploy does when a machine fails its check.

References

The Fly configuration reference for each stanza we walked, the health-check the proxy runs against /health, the region placement, and the Phoenix release the image boots.

Sources

Related in this course