nerves_system_br: what it actually is, and where your OTP version is decided
An afternoon spent finding where a Nerves target's Erlang version is really decided — and discovering it is encoded in the filename of a patch inside a dependency I had never opened. Plus the Kconfig default that will silently move you to OTP 29 the moment you bump.
A standalone post. Not part of a series — just findings from an afternoon of reading, written down in case they save someone else the same afternoon.
I have built and published a custom Nerves system. I have a nerves_defconfig I
understand line by line, a kernel fragment I wrote, a fwup.conf I debugged for
days. And until this week I could not have told you, with any precision, what
nerves_system_br was.
It sat in my mix.exs as a dependency I never touched:
{:nerves_system_br, "~> 1.33", runtime: false}
Then mix firmware refused to build:
** (Mix) Major version mismatch between host and target Erlang/OTP versions
Host version: 29
Target version: 28
And I went looking for where, exactly, my target's OTP version was decided. It is not in my system repo. Here is what I found.
Four layers, not two
I had been thinking about this as two things: my app, and my Nerves system. It is four, and each one configures the one below it.
my_firmware Elixir app — mix.exs, lib/, config/
│
nerves_system_stm32mp157f_dk2 MY system: nerves_defconfig, kernel fragment,
│ U-Boot fragment, fwup.conf, rootfs overlay
nerves_system_br the Nerves build platform
│
Buildroot 2025.11.3 the actual cross-compilation build system
The thing that surprised me is how thin my own system repo is. Strip it down and it is a Buildroot defconfig, a kernel config fragment, a bootloader fragment, an fwup config, and some board files. That is it. Everything that makes it a Nerves system rather than a plain Buildroot project comes from the layer underneath.
It is a BR2_EXTERNAL tree
nerves_system_br is a Buildroot BR2_EXTERNAL tree. Its external.desc is
two lines and says so plainly:
name: NERVES
desc: Nerves br2-external tree
BR2_EXTERNAL is Buildroot's official mechanism for keeping your customisation
outside the Buildroot source tree — your own packages, your own Kconfig, your own
board files — so you never have to fork Buildroot to add something. Nerves uses it
exactly as intended.
Concretely, five things live in there.
1. create-build.sh — the thing that fetches Buildroot
This is where the Buildroot version is pinned, and it is a plain shell variable:
NERVES_BR_VERSION=2025.11.3
That single line determines the version of every package in your system. Kernel, U-Boot, OpenSSL, BusyBox, Erlang — all of it. Not your defconfig. This line.
2. patches/buildroot/ — patches to Buildroot itself
Nineteen of them, in the version I am on. This is the part I did not expect, and it is the most interesting part. A sample of what they do:
NERVES_DEFCONFIG_DIR-support
Install-Linux-headers-to-staging
toolchain-external-refine-support-library-search-to-
Add-customizable-linux-logo
erlang-support-OTP-21-28
erlang-clean-up-target-installation
erlang-don-t-install-to-target
erlang-force-configure-options
erlang-use-available-memory-for-used-memory-calculat
Five of nineteen are about Erlang. That should tell you something about how much of Nerves is "teach Buildroot to treat the BEAM as a first-class citizen".
NERVES_DEFCONFIG_DIR-support is the one that makes my own repo work at all — it
is why my defconfig can write ${NERVES_DEFCONFIG_DIR}/post-build.sh and have that
resolve to my directory.
3. board/nerves-common/ — the shared rootfs
skeleton/ the base directory structure of the rootfs
rootfs_overlay/ files laid on top — erlinit.config and friends
busybox.config one BusyBox config, shared by every Nerves system
post-build.sh runs after Buildroot assembles the rootfs
device_table.txt
My defconfig points at these rather than defining its own:
BR2_ROOTFS_SKELETON_CUSTOM_PATH="${BR2_EXTERNAL_NERVES_PATH}/board/nerves-common/skeleton"
BR2_PACKAGE_BUSYBOX_CONFIG="${BR2_EXTERNAL_NERVES_PATH}/board/nerves-common/busybox.config"
That is why every Nerves device feels the same regardless of board. It is literally the same skeleton.
4. package/ — the Nerves-only Buildroot packages
erlinit the Nerves init — replaces systemd/sysvinit entirely
nbtty the serial console handler
nerves-config
nerves_heart hardware watchdog integration
nerves_initramfs
boardid
erlinit is the one worth understanding. A Nerves device has no init system in the
normal sense. erlinit is PID 1, it mounts the application partition, and it
starts the BEAM. If the BEAM dies, erlinit restarts it. That is the whole process
model, and it ships from here.
5. nerves.mk — the glue that makes cross-compiling work
This one I had used for months without knowing it existed. It is the file that
turns Buildroot's output into something Mix and elixir_make can use:
NERVES_TOOLCHAIN=$(NERVES_SYSTEM)/host
NERVES_SDK_SYSROOT=$(NERVES_SYSTEM)/staging
ERTS_DIR=$(wildcard $(NERVES_SDK_SYSROOT)/usr/lib/erlang/erts-*)
CROSSCOMPILE=$(subst -gcc,,$(firstword $(wildcard $(NERVES_TOOLCHAIN)/usr/bin/*gcc)))
AR=$(CROSSCOMPILE)-ar
AS=$(CROSSCOMPILE)-as
If you have ever added a dependency with a NIF or a port — and I recently added Scenic, which compiles C against cairo and freetype — this is what makes it find the target's libraries instead of your laptop's. When someone says "the C build can't find the sysroot", this is the file they mean.
So where does the OTP version come from?
Here is the chain, and the answer is nowhere near where I expected.
My defconfig says only this:
BR2_PACKAGE_ERLANG=y
BR2_PACKAGE_ERLANG_SMP=y
No version. So it takes the default. The default comes from a Kconfig choice that does not exist in stock Buildroot — it is added by one of those nineteen patches:
patches/buildroot/0007-erlang-support-OTP-21-28.patch
Read that filename again. OTP 21 through 28. The set of Erlang versions my system can build is determined by the name of a patch file in a dependency I never opened.
Which means: my system cannot build OTP 29. Not "is not configured to" — cannot. Buildroot does not know OTP 29 exists until that patch teaches it.
To check what your own system actually shipped:
[HOST] - 1
find ~/.nerves/artifacts -name OTP_VERSION -exec cat {} \;
Mine said 28.4.1. My laptop was running 29.0.2. Hence the error.
The bit that will bite someone
nerves_system_br 1.34 bumps Buildroot to 2026.05 and ships a patch named
erlang-support-OTP-21-29. Good. But look at the Kconfig default it adds:
choice
bool "Erlang/OTP version"
default BR2_PACKAGE_ERLANG_29
The default moved. If your defconfig has a bare BR2_PACKAGE_ERLANG=y with no
version — as mine does, as I suspect most do — then bumping nerves_system_br from
1.33 to 1.34 silently moves your target from OTP 28 to OTP 29. You will find out
when your laptop's OTP no longer matches, or worse, when something subtle changes
on a device in the field.
The fix is one line, and I would argue every custom system should have it regardless:
BR2_PACKAGE_ERLANG=y
BR2_PACKAGE_ERLANG_28=y
BR2_PACKAGE_ERLANG_SMP=y
Pin it explicitly. Then a Buildroot bump changes one thing instead of two, and when you do want OTP 29, that is a deliberate one-line commit you can bisect.
The versioning scheme is not semver, and says so
I assumed ~> 1.33 was a normal caret-ish constraint. It is not. From their
CHANGELOG, which is refreshingly blunt:
This project does NOT follow semantic versioning.
- Major — breaking updates to the build infrastructure. Very rare.
- Minor — every major Buildroot release. Buildroot makes four a year. Major Erlang/OTP updates are held off until the next Buildroot release.
- Patch — Buildroot minor releases, Erlang minor/patch releases, bug fixes.
Read that as a risk scale:
| Bump | What moves | Risk |
|---|---|---|
| Patch (1.33.8 → 1.33.9) | Buildroot point release, OTP patch release | Low — take these |
| Minor (1.33 → 1.34) | A whole Buildroot release, possibly a major OTP | Plan for it |
| Major | Build infrastructure itself | Rare, read everything |
A minor bump moves every package version in your system at once. On a board with any toolchain sensitivity — mine needs a specific external toolchain because the standard one produces U-Boot binaries that hang silently on STM32MP1 — that is exactly the change that can quietly break your boot chain.
How to go and look for yourself
Nothing here required reading source I did not have. It is all sitting in deps/
after a mix deps.get.
[HOST] - 1
grep -m1 NERVES_BR_VERSION deps/nerves_system_br/create-build.sh
[HOST] - 2
ls deps/nerves_system_br/patches/buildroot/
[HOST] - 3
ls deps/nerves_system_br/package/
[HOST] - 4
find ~/.nerves/artifacts -name OTP_VERSION -exec cat {} \;
[HOST] - 5
mix hex.info nerves_system_br
Five commands and you know which Buildroot you are on, which OTP versions you could build, what Nerves adds on top, what you actually shipped, and what is available.
What I took away
Your Nerves system repo is smaller than you think. Most of what makes a Nerves device a Nerves device — the init, the rootfs skeleton, the BusyBox config, the cross-compilation glue — lives one layer down and you inherit it.
A dependency you never open can decide your runtime. The set of Erlang versions my board can run is encoded in a patch filename. That is not a criticism of Nerves; it is a reminder that "I did not configure that" is not the same as "that is not configured".
Pin your OTP version explicitly, even if you are happy with the default today. The default is not yours and it moves.
Read the CHANGELOG of your build platform. Not the release notes of your app's dependencies — the thing that decides your kernel, your libc and your BEAM. Theirs tells you exactly how much risk each digit carries, and I had never looked.
I maintain nerves_system_stm32mp157f_dk2,
a Nerves system for the STM32MP157F-DK2. If you are building your own system for a
board nobody has packaged yet, the
series on building it
covers the boot chain, device trees and the 25+ build errors it took to get there.