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.

WhatVersionWhere it is written
Erlang/OTP28.5.0.4.tool-versions, Dockerfile (OTP_VERSION), .devcontainer/Dockerfile
Elixir1.20.2 (OTP 28).tool-versions, Dockerfile (ELIXIR_VERSION), .devcontainer/Dockerfile
Debiantrixie-20260713Dockerfile (DEBIAN_VERSION; ELIXIR_IMAGE is derived from it)
ffmpeg / ffprobe7.1.5Debian trixie's ffmpeg package; the major is asserted in CI, per architecture

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 stack

with 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 -version must report that major. A bump that moves it fails the pipeline until this file moves with it.
  • The test and runtime stages must report the same ffmpeg build, so the encoder the argv contract is checked against is the one that ships.
  • Both published architectures must report the same full ffmpeg version. The image is published as a manifest list holding linux/amd64 and linux/arm64, and the image-ffmpeg job runs once per architecture on native hardware; the ffmpeg-arch-parity job then compares the two Debian version strings, not just their majors.

If the architectures ever diverge

One version is recorded above because there has only ever been one: Debian builds a source package once per suite and every architecture takes the same version. That is the normal case, not a guarantee — an architecture can lag a security upload by days, and when it does, ffmpeg-arch-parity goes red naming both versions.

That failure is a decision, not a flake to retry. Either wait for the slower architecture's build to land, or accept the split deliberately — in which case the ffmpeg row above becomes two rows, one per architecture, and the release notes say which architecture renders with which encoder. A mixed-version release is a real difference in output bytes for the same URL, which is the same class of change a pin bump is, and it belongs in the notes for the same reason. FFMPEG_MAJOR stays a single line either way: a divergence that moved the major is not a release to reconcile, it is a release to stop.

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:

  1. Edit Dockerfile (DEBIAN_VERSION, ELIXIR_VERSION, OTP_VERSION), .tool-versions and .devcontainer/Dockerfile together; Elixir and OTP move as a pair.
  2. 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
  3. Update the table above, and FFMPEG_MAJOR if the major moved. If it did, expect the argv contract to need real work — run the full :ffmpeg suite and read the failures rather than adjusting the assertion.
  4. 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.
  5. 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.
  6. Run bin/smoke-image locally, then let CI run it again.
  7. Cut a patch release (see docs/development.md).