F7.4 · deep dive 1 · the distilled fly.toml
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
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
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
- Fly.io — fly.toml configuration reference — the
[build],[deploy],[env],[[vm]],[[services]], and[[services.http_checks]]stanzas walked here. - Fly.io — Health checks — the HTTP check the proxy runs against
/health, and what marks a machine healthy. - Fly.io — Regions —
primary_regionand thefra(Frankfurt) placement. - Phoenix — Deploying with releases — the release the image runs and the boot the
[env]configures.
Related in this course
- F7.4 · fly.toml & the deploy mechanics — the module hub this dive opens.
- F7.4.2 · Secrets & env provisioning — where the two secrets are set and the raise contract that catches a missing one.
- F7.4.3 · The deploy lifecycle & failure modes — the absent
release_commandand what a rolling deploy does on a failed check. - F7 · Deploy to Fly — the chapter that frames the whole deploy.