jonnifyelixir
F7.1 · release image / F7.1.2 · toolchain pinning

F7.1 · dive 2 of 3

Toolchain pinning & image coherence

F7.1.2

A 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

IMAGE · echo/Dockerfile OTP 28.5.0.1 RELEASE · compiled with OTP 28.5.0.1 DEV · .tool-versions OTP 28.5.0.1 image ↔ release: agree release ↔ dev: agree dev ↔ image: agree
All three corners read OTP 28.5.0.1 — coherent. The artifact is portable across image, release, and shell.

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

Related in this course