In the last post, you saw how to handcraft a single-platform Open Container Initiative (OCI) image. That was a great learning experience, but let’s be honest —- it was a lot of work! Thankfully, there are tools that make this process much easier. In this post, you’ll learn how to use Buildroot to automate building OCI images and take the pain out of the process.
What is Buildroot?
Buildroot lets you create custom Linux distributions for embedded systems using a Makefile-based approach. Instead of relying on package managers like apt
or yum
, Buildroot compiles everything from source. It handles dependencies automatically, and it can even include precompiled binaries when you need them.
By managing the entire build process, Buildroot removes the need for a package manager in your image, making things more secure. Plus, it can cross-compile for different architectures, so you can build for almost any platform from your own machine.
Buildroot isn’t just for embedded systems. One of its coolest features is the ability to build file systems and package content in multiple formats. That includes support for OCI archives, allowing you to design, build, and generate images that are customized to your needs.
Building a Distribution (But Not an OS)
A “distribution” usually means a full operating system, but for OCI images, you only need the binaries and their dependencies. These binaries call the host’s kernel, so you don’t need a complete OS inside your image. We explored that a bit in the previous post.
Why do most images start from something like ubuntu:latest
? Often, the reason is simple – it’s easier. Most dependencies are already baked in, and package management makes it simple to add missing functionality. Buildroot offers a different approach. You can create an image that includes only the binaries you need. It won’t run as a standalone OS, but it’s perfect for OCI images.
Preparing your environment
Buildroot needs some standard development tools to create images and file systems. If you’re on Ubuntu (or using a dev container based on Ubuntu), you can install the necessary files with this script:
1#!/bin/bash
2
3export LANG=en_US.UTF-8
4export LANGUAGE=en_US:en
5
6## These must be present for Buildroot to work properly.
7REQUIRED_PACKAGES=(bash bc binutils build-essential bzip2 cpio diffutils file g++-14 gcc-14 gzip make patch perl rsync sed tar unzip wget)
8
9## These are recommended packages that are not strictly required, but are often needed for the packages you will want to build.
10RECOMMENDED_PACKAGES=(coreutils curl git libncurses5-dev python3)
11
12## These are supporting packages that are often needed on the host system to build images.
13SUPPORTING_PACKAGES=(software-properties-common libdevmapper-dev libsystemd-dev locales locales-all zip libgnutls28-dev libssl-dev)
14
15sudo apt-get update -qqq
16sudo apt-get install -qq -y ${REQUIRED_PACKAGES[@]} ${RECOMMENDED_PACKAGES[@]} ${SUPPORTING_PACKAGES[@]}
Once these are installed, you’re ready to go!
Get Buildroot
Grab the Buildroot source from the official repository. An easy way to do this is to clone the repository to your computer. If you’re using Git, you can also add it as a submodule:
1git submodule add https://gitlab.com/buildroot.org/buildroot.git
To ensure that you get a specific version of their system, you can pin the module to a specific commit by checking out one of the release tags:
1pushd buildroot
2git checkout 2025.02
3popd
4git add buildroot
5git commit -m "Pinned Buildroot to 2025.02"
Customize your build
To add your settings and components, you need to create some configuration files. While you can directly modify the Buildroot code, the recommended approach is it to put your customizations in an “external tree”. First, create a directory to hold your customizations. For this example, let’s call the directory source
. Within it, you need three files: Config.in
, external.mk
, and external.desc
1mkdir source
2touch source/Config.in source/external.mk source/external.desc
Edit source/external.desc
to give your project a name and description. The project name should be an uppercase identifier. This will be used to generate several other variables.
1name: MYPROJECT
2description: My custom Buildroot project
Buildroot will use for two purposes. First, it will display it as part of the menu configuration. Second, it will generate a variable that can be used to reference your external tree. In this case, it creates BR2_EXTERNAL_MYPROJECT_PATH
.
Add your configuration
Inside source
, add a configs
directory and an empty configuration file:
1mkdir source/configs
2touch source/configs/base_defconfig
You’ll also want an overlay
directory. This contains files that should be directly copied to any generated file systems or images. For this example, create a text file in the root of the file system.
1mkdir source/overlay
2echo "Hello, World!" > source/overlay/hello.txt
With these files in place, you can create a configuration file that contains the build options. Edit source/configs/base_defconfig
using this sample:
1# Target architecture (x86_x64, standard processor features)
2BR2_x86_64=y
3
4# Build SDK details. Using GCC 14 and binutils 2.44
5BR2_BINUTILS_VERSION_2_44_X=y
6BR2_GCC_VERSION_14_X=y
7BR2_TOOLCHAIN_BUILDROOT_CXX=y
8BR2_GCC_ENABLE_OPENMP=y
9# Enable the cache (and an experimental per-package cache)
10BR2_CCACHE=y
11BR2_PER_PACKAGE_DIRECTORIES=y
12# No full init system needed for OCI
13BR2_INIT_NONE=y
14# Overlay files
15BR2_ROOTFS_OVERLAY="$(BR2_EXTERNAL_MYPROJECT_PATH)/overlay/"
16# Bash shell
17BR2_PACKAGE_BASH=y
18# File system types
19BR2_TARGET_ROOTFS_EXT2=y
20BR2_TARGET_ROOTFS_EXT2_4=y
21BR2_TARGET_ROOTFS_EXT2_MKFS_OPTIONS="-O 64bit"
22# OCI image config
23BR2_TARGET_ROOTFS_OCI=y
24BR2_TARGET_ROOTFS_OCI_ENTRYPOINT="bash"
25BR2_TARGET_ROOTFS_OCI_TAG="project-test"
26BR2_TARGET_ROOTFS_OCI_WORKDIR="/"
27BR2_TARGET_ROOTFS_OCI_ENV_VARS="IS_MY_PROJECT=true"
28BR2_TARGET_ROOTFS_OCI_ARCHIVE=y
29
30# Additional packages that are built on the host system and use to
31# create the image. This includes `sloci`, a tool for generating
32# OCI images
33BR2_PACKAGE_HOST_GENEXT2FS=y
34BR2_PACKAGE_HOST_GENIMAGE=y
35BR2_PACKAGE_HOST_QEMU=y
36BR2_PACKAGE_HOST_QEMU_SYSTEM_MODE=y
37BR2_PACKAGE_HOST_SLOCI_IMAGE=y
38BR2_PACKAGE_HOST_ZIP=y
39BR2_PACKAGE_HOST_ZSTD=y
Now you have a configuration that builds an OCI image with Bash and your hello.txt
file.
Build the image
The process of building the image with an external tree begins by having Buildroot create a folder that will contain your work files and and generated images. For our example, you will create a folder called output
:
1make BR2_EXTERNAL=${PWD}/source/ -C buildroot O=${PWD}/output/ base_defconfig
The make
command will run in the buildroot
directory, setting the BR2_EXTERNAL
variable to point to your customizations in the source
directory. It will create the output directory (O=
), then use the base_defconfig
file from the source/configs
directory to configure the contents of the folder.
This directory should not be committed to source control, so make sure to add it to your .gitignore
file:
1echo "output/" >> .gitignore
Run your first build
To build the image using all available CPU cores:
1make -C output -j`nproc`
The make
command is run targeting the output
directory, using -j
to request multiple, parallel builds when possible. The build will likely take around 15-30 minutes, depending on your system and the number of CPU cores available. The majority of this time will be spent downloading compiling tools needed for creating the image. Buildroot caches the source code and compiled files, so subsequent builds may only require a few minutes.
When the build is complete, you will find the resulting OCI image in the output/images
directory. Since the image is configured as project-test
, the resulting image file will be named rootfs-oci:project-test-amd64-linux.oci-image.tar
.
You can now upload the generated file to your registry using Skopeo or a similar tool:
1# Authenticate with the registry
2docker login ghcr.io --username you --password-stdin
3
4# Copy the contents from the archive to a Docker-compatible registry
5skopeo copy oci-archive:output/images/rootfs-oci:project-test-amd64-linux.oci-image.tar docker://ghcr.io/yourorg/project-test
Congratulations! You’ve built and uploaded an OCI image with Buildroot.
Using the menu system to configure your build
To start with, I provided you with a sample configuration file. I also mentioned you won’t need to edit it directly. Unlike most infrastructure0as0code systems, Buildroot does not recommend manually maintaining the file by hand. Instead, it provides a menu-driven configuration system that allows you to easily select the options you want. As you configure the options, it will automatically update the configuration file and incorporate any required dependencies. It will also remove options that are not available on the target platform. In short, it saves you from configuration mistakes.
It’s easy to create a configuration from scratch. You create the output
directory and specify a configuration file. You can select any configuration from the buildroot/configs
directory. For example, if you want to start with a configuration optimized for QEMU images and the x86_64 architecture, you can run the following command:
1make BR2_EXTERNAL=../source/ -C buildroot O=../output/ qemu_x86_64_defconfig
This will create an output
directory with a .config
file that is nearly 5000 lines long! It contains values for all of the available settings and options, including dependencies and unselected options. The settings from the selected configuration are all enabled and configured. Buildroot looks at both the external tree and the main tree in the config
folder for the requested file, then uses that to setup the environment.
I’ve mentioned that these files are not intended to be directly edited. The recommended way to edit the file is to use the menu system to select the options you want. There are a few options available to start the menu system, but I prefer the new curses-based interface:
1make -C output nconfig
This opens a graphical interface to adjust the configuration settings, enable cross-compilation, and select packages and file systems for your build. When you save the settings, it will update the .config
file with the new values. When you are done editing, you can use this command to save a configuration file in your configs
folder:
1make -C output savedefconfig BR2_DEFCONFIG=${PWD}/source/configs/base_defconfig
This command removes all of the default settings, leaving you with a minimized configuration. You can take advantage of this to create multiple configurations for different platforms, architectures, or target environments. Unfortunately, it doesn’t have a way to allow configurations to be shared, so each configuration must contain all of the settings it will use. That said, you can craft a process that merges multiple files together, then creates the output directory.
The ARM64 issue
If you’re on ARM64 (like macOS or a GitHub ARM runner), there’s a known issue with OCI image options. It prevents those environments from seeing or using the OCI image options. To work around this issue, you can use the following command to patch the Buildroot configuration to add support for ARM64 hosts:
1echo ' default y if BR2_HOSTARCH = "aarch64"' >> buildroot/package/go/go-bootstrap-stage1/Config.in.host
Next steps
Now you know how to use Buildroot to create OCI images! With a bit of automation, you can make this a powerful part of your workflow. In a future post, I’ll show you how to speed up builds even more and share some other advanced tricks.