jonnifyelixir
F7.1 · release image / F7.1.1 · multi-stage anatomy

F7.1 · dive 1 of 3

F7.1.1

Multi-stage anatomy & the scoped build

A mix release bundles the app, its dependencies, and the BEAM into one self-contained tree — but a release is not an image. echo/Dockerfile turns the F6.08.2 release into a bootable image with three FROM stages, each kept for one reason. elixir-base pins a Debian image to a known Elixir and OTP with hex and rebar installed. elixir-build compiles the scoped Portal release from the umbrella root — mix deps.get --only prod, mix compile, mix phx.digest, then mix release portal — producing /app/_build/prod/rel/portal. runtime is a debian:bookworm-slim that carries only that assembled release plus the operating-system libraries the BEAM links against, and carries no compiler at all. Two decisions shape the build: the COPY order is arranged so that a source edit does not re-resolve dependencies, and the release names only the Portal apps — echo_bot is never copied in.

We read echo/Dockerfile and echo/mix.exs as built: the three stages, the manifest-first layer caching, the umbrella-root build, and the releases/0 scope that leaves the bot out.

Three stages, three reasons

A multi-stage Dockerfile declares several images in one file and copies artifacts forward between them, so the final image keeps the build’s output without keeping the build’s tools. echo/Dockerfile uses three. elixir-base is hexpm/elixir:…-erlang-…-debian-bookworm-… — a glibc Debian image carrying a fixed Elixir and OTP, with hex and rebar installed and the apt build tools present. elixir-build derives from it and runs the compile and release. runtime starts over from debian:bookworm-slim and installs only the shared libraries the BEAM needs at run time — openssl, libstdc++6, libncurses6, and ca-certificates — then copies the release in. No Elixir, no compiler, no apt build tools ship in it.

The three stages of echo/Dockerfile

STAGE 1 — elixir-base hexpm/elixir · pinned Elixir + OTP · hex + rebar installed FROM #1 STAGE 2 — elixir-build (FROM elixir-base) deps.get · deps.compile · compile · phx.digest · mix release portal FROM #2 COPY --from=elixir-build /app/_build/prod/rel/portal STAGE 3 — runtime (FROM debian:bookworm-slim) the release + openssl · libstdc++6 · libncurses6 · ca-certificates FROM #3

The builder needs the whole Elixir toolchain; the running server needs none of it. Splitting the two across FROM stages is how the shipped image stays small and carries only the release and the libraries it links against.

Manifest-first layer caching

Each instruction in the elixir-build stage produces a layer, and Docker reuses a cached layer until one of its inputs changes; once a layer is invalidated, every layer after it re-runs. The build is ordered to exploit that. It copies the rarely-changing manifests first — the umbrella-root mix.exs and mix.lock, the config, then each Portal app’s own mix.exs — and resolves dependencies (mix deps.get --only prod, mix deps.compile) before it copies lib and priv. So an edit to application source invalidates the source-copy layer and everything after it, but the dependency layers above are reused; a changed dependency in a manifest invalidates the whole chain. Pick what changed since the last build and watch where the cache breaks.

Layer-cache simulator · what changed since the last build?

1   COPY mix.exs mix.lock + each app’s mix.exs CACHED 2   mix deps.get --only prod CACHED 3   mix deps.compile CACHED 4   COPY apps’ lib/ + priv/ (source) CACHED 5   mix compile CACHED 6   mix phx.digest CACHED 7   mix release portal CACHED

Order is the optimisation: the inputs that change least often sit highest, so an ordinary source edit re-runs only the compile-and-release layers and reuses the dependency layers above — the manifests come first precisely so a code change does not re-resolve every dependency.

The umbrella-root, Portal-scoped build

The elixir-build stage runs from the umbrella root, not from inside an app directory. The umbrella-root mix.exs and mix.lock live above the apps, so running there is what lets mix resolve the whole project at once and assemble a release spanning several apps. The build copies only three apps’ manifests and source — apps/portal, apps/portal_web, and apps/echo_data — and apps/echo_bot is never copied in. The scope is set in code: echo/mix.exs releases/0 names portal with exactly portal_web, portal, and echo_data. The bot is its own out-of-band release (F6.08.2 INV6), so a grep echo_bot over the assembled release comes back empty.

# echo/Dockerfile — the elixir-build stage (skeleton), built from the umbrella ROOT
FROM elixir-base AS elixir-build

# manifests FIRST — the rarely-changing inputs, so deps are not re-resolved on a source edit
COPY mix.exs mix.lock ./
COPY config config
COPY apps/portal/mix.exs apps/portal/
COPY apps/portal_web/mix.exs apps/portal_web/
COPY apps/echo_data/mix.exs apps/echo_data/    # echo_bot's mix.exs is NOT copied

RUN mix deps.get --only prod                   # resolve prod deps once, above the source layers
RUN mix deps.compile

# THEN the source — an edit here invalidates from here down, deps above stay cached
COPY apps/portal/lib apps/portal/lib
COPY apps/portal_web/lib apps/portal_web/lib
COPY apps/echo_data/lib apps/echo_data/lib

RUN mix compile
RUN mix phx.digest                              # fingerprint static assets into priv/static
RUN mix release portal                          # -> /app/_build/prod/rel/portal

echo/mix.exs releases/0 — what the portal release contains

RELEASE · portal portal_web : :permanent portal : :permanent echo_data : :permanent NOT IN THE RELEASE apps/echo_bot its own out-of-band release (F6.08.2 INV6)

Scope is declared, not implied: releases/0 names the three Portal apps, the build copies only those three, and the bot stays out. The release that ships is exactly the platform, nothing more.

a release that builds

F6.08.2 assembled portal into /app/_build/prod/rel/portal — it compiles and passes build-local.

the same release, shipped in a slim image

the runtime stage copies that tree whole into debian:bookworm-slim, carrying the BEAM’s shared libraries and no compiler.

The image now builds and is scoped to Portal. But an artifact built in one place must run in the next: the image, the release, and the dev shell have to agree on the OTP and Elixir version, or a fresh mix command breaks. That coherence is the next dive, F7.1.2 · toolchain pinning; then F7.1.3 ships the config files the runtime reads at boot.

References

The multi-stage build, the build cache it exploits, the scoped release, and the umbrella shape it assembles.

Sources

Related in this course