What the release image is built from. .tool-versions pins Elixir and
Erlang/OTP for the repository — mise reads it locally, erlef/setup-beam
reads it in CI. This file pins the image, and the two have to say the same
thing about Elixir and OTP: the release the container runs is compiled by the
toolchain named here.
| What | Version | Where it is written |
|---|---|---|
| Erlang/OTP | 28.5.0.4 | .tool-versions, Dockerfile (OTP_VERSION), .devcontainer/Dockerfile |
| Elixir | 1.20.2 (OTP 28) | .tool-versions, Dockerfile (ELIXIR_VERSION), .devcontainer/Dockerfile |
| Debian | trixie-20260713 | Dockerfile (DEBIAN_VERSION; ELIXIR_IMAGE is derived from it) |
| ffmpeg / ffprobe | 7.1.5 | Debian trixie's ffmpeg package; the major is asserted in CI |
ELIXIR_IMAGE is derived from DEBIAN_VERSION in the Dockerfile rather than
written out, because the build and runtime stages must name the same Debian
version: the ffmpeg the argv contract is tested against has to be the package
the runtime stage installs.
Why Debian and not Alpine
The image was Alpine first, per the original stack decision, and had to move. On musl the BEAM intermittently aborts during startup:
sys_signal_stack.c:103:sys_sigaltstack(): Internal error: Failed to set alternate signal stackwith exit code 134 (SIGABRT). It is host-dependent, not load-dependent: the size
OTP requests for the JIT's alternate signal stack can fall below the kernel's
MINSIGSTKSZ, which grows with CPU features, and OTP's workaround for this only
ever worked against glibc.
This was measured at 2 failures in 10 runs on GitHub's runner fleet, in the
runtime container — the shipped image failing to boot, not a flaky build. On a
mixed fleet roughly one container start in five would have died, and the failure
never reproduces on a developer machine, which is what makes it worth this much
prose. It was caught by the container smoke suite before v0.1.0 was tagged.
Two consequences worth keeping in mind:
- Do not "optimise" the image back to Alpine for size. It costs about 80 MB over the Alpine equivalent. That is the price of a release that boots.
- The devcontainer was Debian all along, so dev and prod now agree on ffmpeg instead of differing by a major. The gap this file used to document is closed.
What CI asserts
The ffmpeg major is recorded once, on the line below, and both
bin/smoke-image and the image-ffmpeg CI job parse that
line. It is written as a key/value rather than as prose so there is exactly one
place to change and no second copy to fall out of step:
FFMPEG_MAJOR=7- The runtime image's
ffmpeg -versionmust report that major. A bump that moves it fails the pipeline until this file moves with it. - The
testandruntimestages must report the same ffmpeg build, so the encoder the argv contract is checked against is the one that ships.
Bumping a pin
A pin bump changes what the image renders — a different encoder emits different bytes for the same URL — so it is a release, not a silent update. The procedure:
- Edit
Dockerfile(DEBIAN_VERSION,ELIXIR_VERSION,OTP_VERSION),.tool-versionsand.devcontainer/Dockerfiletogether; Elixir and OTP move as a pair. - Rebuild and read the new ffmpeg version out of the image:
docker build -t audio_proxy:pin-check . && docker run --rm --entrypoint ffmpeg audio_proxy:pin-check -version - Update the table above, and
FFMPEG_MAJORif the major moved. If it did, expect the argv contract to need real work — run the full:ffmpegsuite and read the failures rather than adjusting the assertion. - Regenerate the measured memory table:
bin/measure-ffmpeg-rss --write docs/capacity.md. A different ffmpeg holds different memory, and docs/capacity.md is what an operator sizes a container from — a stale table is a wrong memory limit. Commit the regenerated table with the bump; a bump that moves the numbers noticeably belongs in the release notes. - Regenerate the decision matrix on top of it:
bin/capacity-matrix --write docs/capacity.md. It reads the table step 4 just rewrote, so it has to run second, and it is what an operator actually reads — a correct table under a stale matrix is a wrong memory limit with an audit trail. Needs no docker; commit both in the same change. - Run
bin/smoke-imagelocally, then let CI run it again. - Cut a patch release (see docs/development.md).