There is no better way to understand what a Linux system actually is than to assemble one yourself. MiniLinux-From-Scratch is a short tutorial that builds a small, self-contained distribution derived from Alpine Linux: a kernel you compile, a root filesystem borrowed from a Docker image, a bootloader, and a single disk image you can boot in QEMU.

The method is intentionally generic. Nothing in it is specific to Alpine, so the same steps can be adapted to another base distribution. This post follows the repository’s README and adds the reasoning behind each step.

Several commands run as root (losetup, mount, grub-install) and operate on block devices. Double-check the loop device number before you format or install anything, and never point them at a real disk.

The big picture

A Linux machine needs surprisingly few things to boot: a kernel, a root filesystem with an init system, and a bootloader that glues them together. We will build each piece separately and assemble them inside one raw disk image.

flowchart LR
  K["Linux kernel<br/>compiled from source"] --> IMG
  D["Alpine container<br/>Docker"] -- "copy bin etc lib root sbin usr" --> FS["Root filesystem<br/>ext4 partition"]
  FS --> IMG[("disk.img<br/>450 MB, MBR")]
  G["GRUB<br/>BIOS boot"] --> IMG
  IMG --> Q(["QEMU<br/>serial console"])

Requirements

Two things are needed before you start:

  • A compiled Linux kernel. It is the core of the system, responsible for hardware and resource management. The next section compiles one.
  • Docker. It is used as a convenient way to obtain a ready-made Alpine userland, not as a runtime for the final system.

You will also use parted, losetup, mkfs.ext4, GRUB and QEMU on the host. On a Debian-based host, the BIOS flavour of GRUB (the i386-pc modules used later) may have to be installed separately.

Compiling the kernel

Download the kernel archive of your choice from kernel.org, extract it, and from its directory run:

make defconfig
scripts/config --set-val CONFIG_BPF_SYSCALL y
scripts/config --set-val CONFIG_BPF_LSM y
scripts/config --set-val CONFIG_DEBUG_INFO_BTF y
scripts/config --set-val CONFIG_FUNCTION_TRACER y
scripts/config --set-val CONFIG_DYNAMIC_FTRACE y
scripts/config --set-val CONFIG_DYNAMIC_FTRACE_WITH_REGS y
make olddefconfig
make -j$(nproc)

make defconfig generates a sensible default configuration for your architecture. Every scripts/config line and the make olddefconfig that follows are optional. They switch on eBPF, BTF type information and the function tracer, which is what you want if you plan to experiment with tracing and eBPF programs in the guest (this is also why the Alpine image later receives clang, llvm, bpftool and libbpf). If you only want a system that boots, skip them.

make -j$(nproc) then builds the kernel on every available core. The result is the compressed image arch/x86/boot/bzImage.

Preparing the disk image

The whole system will live in a single file that behaves like a hard disk.

Create an empty 450 MB file:

truncate -s 450M disk.img

Create a BIOS (MBR) partition table in it:

/sbin/parted -s ./disk.img mktable msdos

Add one primary ext4 partition that fills the whole image:

/sbin/parted -s ./disk.img mkpart primary ext4 1 "100%"

Flag it as bootable, so the BIOS knows where to look:

/sbin/parted -s ./disk.img set 1 boot on

Expose the image and its partitions through a loop device. A loop device lets the kernel treat a regular file as a block device:

sudo losetup -Pf disk.img

Find out which device you got:

losetup -l

From here on, X stands for the number of your loop device (for example /dev/loop0, whose first partition is /dev/loop0p1).

Format the first partition as ext4, then mount it:

sudo mkfs.ext4 /dev/loopXp1
mkdir -p /tmp/my-rootfs
sudo mount /dev/loopXp1 /tmp/my-rootfs

Writing into /tmp/my-rootfs now writes into the partition inside disk.img.

Populating the root filesystem with Alpine

This is the cheap trick that makes the whole tutorial short: instead of building a userland by hand, we start an Alpine container with the mounted partition shared as a volume, configure it, and copy it over.

docker run -it --rm -v /tmp/my-rootfs:/my-rootfs alpine

Everything below happens inside the container.

Install the init system, basic utilities and a toolchain:

apk add openrc util-linux build-base clang llvm bpftool libbpf-dev linux-headers

openrc is Alpine’s init system and util-linux brings the usual tools. The rest (build-base, clang, llvm, bpftool, libbpf-dev, linux-headers) turns the guest into a place where you can compile C and eBPF programs.

Give the system a serial console. QEMU will run without a screen, so the login prompt has to appear on the first serial port:

ln -s agetty /etc/init.d/agetty.ttyS0
echo ttyS0 > /etc/securetty
rc-update add agetty.ttyS0 default
rc-update add root default

Set a root password:

passwd root

Mount the pseudo filesystems at boot. /dev, /proc and /sys are virtual: they expose devices, processes and kernel state:

rc-update add devfs boot
rc-update add procfs boot
rc-update add sysfs boot

Copy the system to the mounted partition:

for d in bin etc lib root sbin usr; do tar c "/$d" | tar x -C /my-rootfs; done

Piping tar into tar preserves permissions, ownership and symlinks, which a plain cp would not do as reliably.

Create the mount points the kernel and init expect, then leave the container:

for dir in dev proc run sys var; do mkdir /my-rootfs/${dir}; done
exit

These directories are empty on purpose: they are only mount points, filled at runtime.

Installing GRUB

Back on the host, the partition is still mounted at /tmp/my-rootfs.

Optionally, edit the welcome message shown before the login prompt:

sudo vim /tmp/my-rootfs/etc/issue

Create the boot directory and copy the kernel you compiled (run the cp from the kernel source directory):

sudo mkdir -p /tmp/my-rootfs/boot/grub
sudo cp linux/arch/x86/boot/bzImage /tmp/my-rootfs/boot/vmlinuz

Write the GRUB configuration in /tmp/my-rootfs/boot/grub/grub.cfg:

serial
terminal_input serial
terminal_output serial
set root=(hd0,1)
menuentry "MiniLinux-From-Scratch" {
  linux /boot/vmlinuz root=/dev/sda1 console=ttyS0
}

Reading it line by line:

  • serial, terminal_input serial and terminal_output serial make GRUB itself talk over the serial port, since there is no display.
  • set root=(hd0,1) tells GRUB where its files are: first disk, first partition.
  • linux /boot/vmlinuz ... loads the kernel. root=/dev/sda1 is the partition to mount as / and console=ttyS0 sends kernel messages and the console to the serial port.

There is no initramfs here. The kernel mounts the root partition itself, which is possible because a default configuration compiles the needed drivers and ext4 straight into the kernel.

Install GRUB for BIOS boot, directly on the loop device (the whole device, not the partition):

sudo grub-install --directory=/usr/lib/grub/i386-pc --boot-directory=/tmp/my-rootfs/boot /dev/loopX

This writes the first-stage loader into the image’s boot sector and places the rest under the boot-directory you pointed it at.

Unmounting and booting

Flush and release everything:

sudo umount /tmp/my-rootfs
sudo losetup -d /dev/loopX

Skipping this step risks booting a partially written filesystem.

Boot the image in QEMU:

qemu-system-x86_64 -hda disk.img -nographic

-nographic disables the graphical window and wires the guest’s serial port to your terminal, which matches the console=ttyS0 we configured.

What happens at boot

Everything you just assembled plays its part, in this order:

sequenceDiagram
  participant B as BIOS
  participant G as GRUB
  participant K as Linux kernel
  participant O as OpenRC
  participant A as agetty on ttyS0
  B->>G: read the MBR of the bootable partition
  G->>G: load grub.cfg and show the menu
  G->>K: load /boot/vmlinuz with root=/dev/sda1
  K->>K: mount ext4 root filesystem
  K->>O: start init
  O->>O: boot level: devfs, procfs, sysfs
  O->>A: default level: start the serial login
  A-->>A: prompt, log in as root

When the login: prompt shows up in your terminal, log in as root with the password you set. To leave QEMU in -nographic mode, press Ctrl+A then X.

Wrapping up

You now have a bootable Linux system in a single 450 MB file, and you know where each piece comes from: the kernel from source, the userland from an Alpine container, and the glue from GRUB. A few directions worth exploring from here:

  • change the base: swap Alpine for another distribution’s container image and see what breaks;
  • trim the kernel configuration instead of starting from defconfig, and watch the image shrink;
  • use the preinstalled toolchain to write and load a first eBPF program inside the guest;
  • script the whole procedure so a fresh image is one command away.

The project is on GitHub. Issues and suggestions are welcome.