F7.1 · dive 2 of 3
Toolchain pinning & image coherence
F7.1.2A release is portable only inside the toolchain it was compiled with. The BEAM does not
ship one frozen virtual machine the way a JVM jar does; a mix release bundles the
ERTS of the OTP that built it, and that ERTS links against the OTP applications the same version installed.
Move the release onto a host whose OTP differs, and the boot can fail to load a module it expected. So three
places must name the same OTP and Elixir: the base image the release is compiled inside, the
release itself (which records the versions it was built against), and echo/.tool-versions
— the dev shell that produces the release locally. echo/Dockerfile pins its base
tag to hexpm/elixir:1.18.4-erlang-28.5.0.1-debian-bookworm-20260518 through the build
args ELIXIR_VERSION, OTP_VERSION, and
DEBIAN_DATE; echo/.tool-versions reads
elixir 1.18.4 / erlang 28.5.0.1. The number is not the lesson.
The agreement is.
Three parts: why the tag is 28.5.0.1 and not the
28.1 the umbrella once documented; the coherence triangle that keeps an artifact
portable across image, release, and shell; and the silent trap when the local pin names an erlang that is not
installed.
Why the tag is 28.5.0.1, not 28.1
The umbrella’s echo/.tool-versions documented OTP 28.1
through F6, and the obvious base tag would have been 1.18.4-erlang-28.1. There is no
such image. Docker Hub’s hexpm/elixir repository publishes a fixed grid of
Elixir×OTP×Debian tags, and its 28.x line jumps from 28.0.4 straight to
28.5.0.1 — no 28.1 tag was ever built. A
FROM naming a tag the registry does not carry fails the build at the first pull, before
a single line compiles.
The resolution recorded in echo/Dockerfile was to pin up to the nearest real
published tag, 28.5.0.1, and then bring the rest of the toolchain to match: the release
is compiled inside that base, and echo/.tool-versions was bumped to
erlang 28.5.0.1 so the dev shell agrees with the image (F6.8.2 D11). The specific
number is incidental — it is whatever Docker Hub actually ships near the version the project wants. What
the module insists on is that all three places carry that same chosen number.
A toolchain pin is only as real as the registry that serves it: the version you write must be a version that exists, and once chosen, every place that names it must name the same one.
The coherence triangle
Three places record an OTP version, and a release built in one runs in the next only if all three agree. The
image fixes the compiler: its base tag is the OTP the release is built inside. The
release records the OTP it was compiled against and bundles that ERTS, so the runtime stage
boots on exactly that. The dev shell — echo/.tool-versions,
read by asdf — is the OTP a local mix release uses. Set each corner to a version
and read where coherence holds or breaks.
The coherence triangle · set each corner’s OTP
image tag — echo/Dockerfile base
release — compiled with
dev shell — echo/.tool-versions
Coherence is a property of the whole triangle, not of any one corner: the image, the release, and the dev shell must agree on the OTP version, or an artifact built at one corner fails at the next.
The uninstalled-pin trap
Pinning the dev shell to match the image creates a trap of its own. asdf reads
echo/.tool-versions on every command in the directory; if that file names an erlang
that is not installed on the machine, asdf has no runtime to dispatch to. Every fresh
mix command then dies before it starts — not with a compile error, but with
No version is set for command erl, because the named OTP is not there at all. A fresh
mix eval or mix release fails for a reason that looks nothing
like a version problem.
The bring-up escape hatch was an environment override: setting
ASDF_ERLANG_VERSION=28.1 points asdf at an OTP that is installed for the life
of the command, sidestepping the pin without editing the file (a companion TMPDIR=/tmp
keeps mix’s scratch space off a full overlay). That unblocks the moment, but it
also re-opens the coherence gap: the local build now runs on a different OTP than the image. The durable fix is
to install the pinned OTP — 28.5.0.1 — so the dev shell matches the image
instead of routing around the pin.
The two lines below are the pin: the echo/Dockerfile base tag and build args on the
left, the echo/.tool-versions contents on the right. They must name the same OTP.
# echo/Dockerfile — the pinned base (build args set the tag) ARG ELIXIR_VERSION=1.18.4 ARG OTP_VERSION=28.5.0.1 # no 1.18.4-erlang-28.1 image exists; pinned UP to a real tag ARG DEBIAN_DATE=20260518 FROM hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-bookworm-${DEBIAN_DATE} AS elixir-base # echo/.tool-versions — the dev shell asdf reads; must equal the image elixir 1.18.4 erlang 28.5.0.1 # if NOT installed locally: "No version is set for command erl"
A pin protects coherence only when the version it names is actually present: an
echo/.tool-versions erlang that is not installed breaks every fresh
mix command until the OTP is installed or the pin is overridden.
one pinned toolchain
A single OTP and Elixir version named by the image tag, recorded in the release, and pinned in
echo/.tool-versions.
artifacts portable
The release built locally, in CI, or in the image runs the same everywhere — portable across the image, the release, and the shell.
The image now boots on a coherent toolchain — but a coherent boot still fails if the runtime
config points at a file the image never shipped. Next,
F7.1.3 · shipping config files in follows the
ERL_INETRC ↔ COPY coupling and the two clean-slate
FROM traps. Back to the F7.1 release-image
hub.
References
The published image grid, the Elixir/OTP support matrix, the version manager
echo/.tool-versions drives, and the OTP release list.
Sources
- Docker Hub — hexpm/elixir image tags — the published Elixir×OTP×Debian tag grid; the source of truth for which base tags actually exist.
- Elixir — Compatibility & deprecations — which Elixir versions support which OTP releases, and why a release must run on the OTP it compiled against.
- asdf — the multiple runtime version manager — the tool that reads
.tool-versionsand dispatches each command to the pinned runtime. - Erlang/OTP — release downloads & version list — the canonical OTP release list, for confirming a version exists before pinning to it.
Related in this course
- F7.1 · The release image — the module hub this dive sits under.
- F7.1.1 · Multi-stage anatomy & the scoped build — the stage that fixes this toolchain.
- F7.1.3 · Shipping config files in — the next dive: the
ERL_INETRC↔COPYcoupling. - F6.08 · Auth, deployment & going live — the release this image carries.