Ken Muse

Building OCI Images With Buildroot


This is a post in the series Working with Buildroot. The posts in this series include:

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.