F7.1 · dive 1 of 3
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
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?
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
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
- Docker — Multi-stage builds — one image, several
FROMstages; copy artifacts forward and leave the toolchain behind. - Docker — Optimize the build cache — how layers are cached and invalidated, and why instruction order matters.
- Mix — mix release — assembling a self-contained release of the app, its deps, and the BEAM.
- Elixir — Dependencies & umbrella projects — the umbrella-root project that resolves several apps at once.
Related in this course
- F7.1 · the release image — the module hub this dive opens.
- F7.1.2 · toolchain pinning & image coherence — one OTP and Elixir version across image, release, and dev shell.
- F7.1.3 · shipping config files in — the clean-slate
FROMtraps behind a missing/app/inetrc. - F6.08 · Auth, deployment & going live — the release this image carries.