F7.2 · deep dive 2 · Fly’s IPv6-only private network
Fly’s IPv6-only private network
Fly’s private network — the 6PN — is IPv6-only. A
.internal name like portal-postgres.internal carries
only an AAAA record. So before a BEAM node can resolve a private name, connect Postgrex to it,
and cluster over it, three independent settings in echo/fly.toml must agree:
ERL_INETRC (the resolver config), ECTO_IPV6 (the Postgrex
address family), and ERL_AFLAGS (the BEAM distribution transport). Two of the three take
effect from being set. The third — ECTO_IPV6 — takes effect only when
runtime.exs reads it, and that gap is the trap the first bring-up walked into.
We read the echo/fly.toml [env] trio against
echo/config/runtime.exs and echo/infra/inetrc as built. The
headline lesson is the set-versus-consume distinction: naming an env var configures nothing the
application does not also read.
Three settings, one v6-only network
The echo/fly.toml [env] block carries three values that share
one purpose: making the BEAM and its database connection speak IPv6 on a network where nothing else is offered.
Each governs a different layer.
ERL_INETRC = "/app/inetrc" points Erlang’s resolver at the
config file F7.1 ships. Its content (echo/infra/inetrc) sets
{lookup, [native, file, dns]} — the native OS resolver first, so
/etc/resolv.conf and both address families are honoured, then
/etc/hosts, then Erlang’s built-in resolver. It names the Fly internal nameserver
{nameserver, {16#fdaa, 0, 0, 0, 0, 0, 0, 3}} (fdaa::3, which
resolves .internal and .flycast within the private network),
the public fallbacks {1, 1, 1, 1} and {8, 8, 8, 8}, and
{search, ["internal"]} so a short name portal-postgres
resolves as portal-postgres.internal, alongside the timeout, retry, and cache terms.
Erlang consumes ERL_INETRC on its own — naming the file in the env
var is enough, provided the file exists (that condition is gotcha 1, F7.1’s concern).
ECTO_IPV6 = "true" is the trap. Setting it in
fly.toml does nothing on its own; it takes effect only where
runtime.exs reads it into the Repo’s socket_options. That
is the next section.
ERL_AFLAGS = "-proto_dist inet6_tcp" makes BEAM
distribution run over inet6, so libcluster’s DNSPoll over
echo-portal.internal (the echo/config/prod.exs
fly_dns topology) forms the cluster on Fly’s IPv6 substrate. This is distinct from
Postgrex’s socket_options: one governs node-to-node distribution, the other the
database connection — and both must speak inet6 on a v6-only network. Like
ERL_INETRC, the BEAM runtime consumes it from being set.
The fly.toml [env] trio · three layers of one v6-only network
Two of the three settings reach the runtime from being named in the deploy config; the middle one,
ECTO_IPV6, reaches nothing until application code reads it.
A vs AAAA — the decisive fix
Postgrex resolves its host with the address family of its socket_options. Left on the
default :inet, it issues an A-record (IPv4) query. Against an
AAAA-only .internal name, an A-query returns
:nxdomain — a connection failure that crash-loops the boot, not a resolver bug.
The name is sound; the question was asked in the wrong address family.
The fix is a single line in echo/config/runtime.exs: the
maybe_ipv6 binding reads the env var, and socket_options on
Portal.Repo carries the result. With [:inet6] in place,
Postgrex issues an AAAA query, the .internal name resolves to its
fdaa:… address, and the boot connects. The empty-list default
(else: []) keeps a local or dev DATABASE_URL — an IPv4
loopback, or the fly proxy migration path — on its default family, unaffected.
Toggle the family below against a fixed AAAA-only host and watch the query, the resolver result, and the boot outcome move together.
# echo/config/runtime.exs — the maybe_ipv6 binding + the Repo socket_options (the decisive line) # Fly `.internal`/`.flycast` names carry ONLY AAAA records. The default `:inet` issues an # A-record query against an AAAA-only host and gets `:nxdomain` — a connection failure, NOT # a resolver bug. `fly.toml` sets ECTO_IPV6=true; THIS line consumes it. maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] case {System.get_env("DATABASE_URL"), env} do {url, _} -> config :portal, Portal.Repo, url: url, pool_size: String.to_integer(System.get_env("POOL_SIZE", "10")), socket_options: maybe_ipv6 # [:inet6] when ECTO_IPV6 is set, else [] end
Resolve portal-postgres.internal (AAAA-only) · pick Postgrex’s socket family
The default :inet A-queries an AAAA-only name and gets
:nxdomain; [:inet6] AAAA-queries it and connects — the
address family, not the network, is the difference.
Set is not consume
Here is the headline, stated plainly. ERL_INETRC and
ERL_AFLAGS are consumed by the BEAM runtime the moment they are set — no
application code reads them. ECTO_IPV6 is consumed only by application code: the
maybe_ipv6 binding and the Portal.Repo
socket_options in runtime.exs. So setting
ECTO_IPV6 = "true" in fly.toml without that code is a silent
no-op — it produces the same :nxdomain as an unreachable host. A
Phoenix-generated runtime.exs ships the consume line by default; a hand-built one (this
umbrella) can set the env var and forget the line.
The map below has three toggles, one per setting. Flip them and read the three independent outcomes: whether the
resolver is visible, whether the database connects over IPv6, and whether the cluster forms over inet6. The case
worth dwelling on: ECTO_IPV6 stays "true" in
fly.toml the whole time — remove only the consume line in
runtime.exs and the database still fails with :nxdomain, while
the resolver and the cluster, which take effect from being set, are unaffected.
The trio consume map · what each setting actually wires
An env var named in fly.toml configures the application only where the
application reads it — ECTO_IPV6 via the
runtime.exs consume line, the other two via the BEAM itself.
set in fly.toml
The deploy config names ECTO_IPV6 = "true", the same line that wired the resolver and the
cluster. Three values in one [env] block.
consumed in runtime.exs
Only one of the three needs a reader: maybe_ipv6 threads ECTO_IPV6 into
Portal.Repo’s socket_options. Without that line, the value sits unread.
A wrong address family and an unreachable host both crash with the identical
:nxdomain. The next dive,
F7.2.3 · .internal vs .flycast, gives the
getent discriminator that tells the two apart, and back at
F7.2.1 is the runtime-vs-compile split that
decides which values runtime.exs reads at all.
References
The IPv6-only private network, the Postgrex socket family that resolves its host, the resolver config Erlang loads, and the clustering that rides the same v6 substrate.
Sources
- Fly.io — Private networking — the IPv6-only 6PN, the
.internalnames, and their AAAA-only resolution. - Postgrex — start_link options —
socket_options, where[:inet6]selects the AAAA address family for the connection. - Erlang/OTP — Inet configuration — what
ERL_INETRCpoints at and how the resolver reads the lookup order and nameservers. - libcluster — Cluster.Strategy.DNSPoll — polling
.internalfor clustering, which needs inet6 distribution on Fly.
Related in this course
- F7.2 · the module hub — the runtime-config module this dive opens.
- F7.2.1 · the runtime-vs-compile split — what
runtime.exsreads at boot and why it readsECTO_IPV6at all. - F7.2.3 · .internal vs .flycast & the discriminator — the
getenttest that isolates the two faults that share:nxdomain. - F7.1 · the release image — the image that ships the
infra/inetrcfileERL_INETRCpoints at.