Skip to content

Instantly share code, notes, and snippets.

@cgwalters
Created March 24, 2026 17:48
Show Gist options
  • Select an option

  • Save cgwalters/9af5ee6848a0e9bebb33f1aa5aad9a0c to your computer and use it in GitHub Desktop.

Select an option

Save cgwalters/9af5ee6848a0e9bebb33f1aa5aad9a0c to your computer and use it in GitHub Desktop.
kairos vs bootc

This document is fully generated by Opus 4.6 with OpenCode.


bootc vs Kairos: Running a Hello-World Apache in QEMU

A new-user comparison of getting a "hello world" Apache httpd system running in QEMU using bootc and Kairos, written March 2026.

Kairos v4.0.1 / Hadron v0.0.4; bootc from current main branch.


The task

Start from nothing. End with an immutable Linux VM booting in QEMU, serving a static "Hello World" page over HTTP on port 80. Compare how easy each project makes this.


bootc: the short version

bootc takes a standard OCI container image and makes it bootable. You write a Containerfile the same way you would for any application container, but the base image includes a Linux kernel and systemd. Services start via systemctl enable, not CMD.

Step-by-step

1. Write a Containerfile

FROM quay.io/fedora/fedora-bootc:41

RUN dnf -y install httpd && dnf clean all
RUN systemctl enable httpd
## Note: /var/www/html/ content won't update on `bootc upgrade` — fine for
## a hello-world demo but in production put content under /usr/share/httpd/
RUN echo '<h1>Hello World from bootc!</h1>' > /var/www/html/index.html

# Let us log in during testing
RUN echo 'root:password' | chpasswd

LABEL containers.bootc 1

That's it. The dnf install / systemctl enable pattern is the same one sysadmins have used for decades; the Containerfile is just the delivery mechanism.

2. Build

podman build -t localhost/my-httpd-bootc .

3. Create a bootable disk image

truncate -s 10G disk.raw
sudo podman run --rm --privileged --pid=host \
  --security-opt label=type:unconfined_t \
  -v /dev:/dev \
  -v /var/lib/containers:/var/lib/containers \
  -v .:/output \
  localhost/my-httpd-bootc \
  bootc install to-disk --generic-image --via-loopback /output/disk.raw

Alternatively, use bootc-image-builder to produce a qcow2 directly:

sudo podman run --rm --privileged \
  -v /var/lib/containers/storage:/var/lib/containers/storage \
  -v ./output:/output \
  quay.io/centos-bootc/bootc-image-builder:latest \
  --type qcow2 \
  localhost/my-httpd-bootc

4. Boot in QEMU

qemu-system-x86_64 -m 2048 -enable-kvm -cpu host \
  -drive file=disk.raw,format=raw,if=virtio \
  -nic user,hostfwd=tcp::8080-:80 \
  -nographic -serial mon:stdio

5. Test

curl http://localhost:8080
# <h1>Hello World from bootc!</h1>

Concept count

You need to understand three things: Containerfiles, bootc install to-disk (or bootc-image-builder), and basic QEMU flags. The only "new" concept beyond normal container building is that CMD, ENTRYPOINT, and EXPOSE are ignored at boot, and environment variables from ENV are not propagated to systemd services — everything runs via systemd.


Kairos: the short version

Kairos is a CNCF sandbox project that turns Linux distros into immutable, container-based operating systems optimized for Kubernetes at the edge. It supports multiple "flavors" (Alpine, Fedora, Ubuntu, and the new default "Hadron"), with built-in k3s/k0s, P2P mesh networking, cloud-config-driven provisioning, and fleet management.

Getting Apache hello-world running is more involved because the project optimizes for a different workflow.

The Hadron problem: no package manager

The default Kairos quickstart uses Hadron, a minimal base with no apt, dnf, or zypper. To install Apache you must either:

  1. Download a statically-linked httpd binary and COPY it in, or
  2. Use a non-Hadron flavor (Ubuntu, openSUSE) via BYOI, which adds its own complexity since official prebuilt artifacts are no longer published for these flavors.

Path A: Using a BYOI flavor with a package manager

This is the closest equivalent to the bootc workflow.

1. Write a Dockerfile

FROM quay.io/kairos/kairos-init:v0.6.8 AS kairos-init

# Use an openSUSE base that has a package manager
FROM opensuse/leap:15.6 AS base

# "Kairosify" the system
RUN --mount=type=bind,from=kairos-init,src=/kairos-init,dst=/kairos-init \
    eval /kairos-init -l debug -s install --model generic --version "0.1.0" && \
    eval /kairos-init -l debug -s init --model generic --version "0.1.0"

# Install Apache
RUN zypper -n install apache2 && zypper clean -a
RUN systemctl enable apache2

RUN echo '<h1>Hello World from Kairos!</h1>' > /srv/www/htdocs/index.html

2. Build and push

docker build -t my-kairos-httpd:0.1.0 .
docker tag my-kairos-httpd:0.1.0 ttl.sh/my-kairos-httpd:0.1.0
docker push ttl.sh/my-kairos-httpd:0.1.0

3. Generate an ISO with AuroraBoot

docker run --rm \
  -v $PWD/build:/tmp/auroraboot \
  -v /var/run/docker.sock:/var/run/docker.sock \
  quay.io/kairos/auroraboot:latest \
  --set container_image=docker://ttl.sh/my-kairos-httpd:0.1.0 \
  --set "disable_http_server=true" \
  --set "disable_netboot=true" \
  --set "state_dir=/tmp/auroraboot"

4. Write a cloud-config

#cloud-config
install:
  device: "auto"
  reboot: false
  poweroff: true
  auto: true

users:
- name: kairos
  passwd: kairos
  groups: [admin]

stages:
  boot:
  - name: "Repart image"
    layout:
      device:
        label: COS_PERSISTENT
      expand_partition:
        size: 0
    commands:
    - |
      [[ "$(echo "$(df -h | grep COS_PERSISTENT)" | awk '{print $5}' \
        | tr -d '%')" -ne 100 ]] \
        && resize2fs /dev/disk/by-label/COS_PERSISTENT

5. Create a cloud-init ISO sidecar

cd build
touch meta-data
cp ../cloud-config.yaml user-data
mkisofs -output ci.iso -volid cidata -joliet -rock user-data meta-data

6. Create a raw disk and run the installer in QEMU (phase 1: install)

truncate -s 20G disk.raw
qemu-system-x86_64 -m 8192 -smp cores=2 \
  -nographic -cpu host -enable-kvm \
  -serial mon:stdio \
  -drive if=virtio,media=disk,file=disk.raw \
  -drive format=raw,media=cdrom,readonly=on,file=kairos.iso \
  -drive format=raw,media=cdrom,readonly=on,file=ci.iso \
  -boot d

Wait for installation to complete and the VM to power off.

7. Boot from disk (phase 2: run)

qemu-system-x86_64 -m 4096 -smp cores=2 \
  -nographic -cpu host -enable-kvm \
  -serial mon:stdio \
  -drive if=virtio,media=disk,file=disk.raw \
  -nic user,hostfwd=tcp::8080-:80

8. Test

curl http://localhost:8080

Path B: Using Hadron (default, no package manager)

If you stick with Hadron, you need to download a static binary:

FROM quay.io/kairos/kairos-init:v0.6.8 AS kairos-init
FROM ghcr.io/kairos-io/hadron-toolchain:v0.0.1-beta2 AS httpd-build

# Download a static httpd (e.g., busybox httpd or similar)
RUN curl -L -o /busybox \
    https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox
RUN chmod +x /busybox

FROM ghcr.io/kairos-io/hadron:v0.0.1-beta2 AS base
ARG VERSION=0.1.0
RUN --mount=type=bind,from=kairos-init,src=/kairos-init,dst=/kairos-init \
    eval /kairos-init -l debug -s install --model generic \
      --provider k3s --version "${VERSION}" && \
    eval /kairos-init -l debug -s init --model generic \
      --provider k3s --version "${VERSION}"

COPY --from=httpd-build /busybox /usr/bin/busybox

Then you still need a systemd unit to run busybox httpd -f -p 80 -h /srv/www, the hello-world HTML dropped via cloud-config, and steps 3-8 from Path A. There is no shortcut for the two-phase install.

Concept count

You need to understand: Dockerfiles, kairos-init (kairosification), AuroraBoot, cloud-config (yip, not standard cloud-init), mkisofs for the sidecar ISO, two-phase QEMU boot (install then run), Kairos partitioning (OEM, recovery, state, persistent), and the difference between flavors. That is roughly 3x the conceptual surface area.


Side-by-side comparison

Dimension bootc Kairos
Goal Make OCI images bootable Immutable edge OS platform with Kubernetes
Base image Standard distro (Fedora, CentOS) with kernel + systemd Hadron (no pkg mgr) or BYOI flavor
Package install dnf install httpd Depends on flavor; Hadron needs static binaries
Service management systemctl enable in Containerfile Same, plus cloud-config stages
Disk image creation bootc install to-disk --via-loopback (one step) AuroraBoot ISO + two-phase QEMU install
QEMU workflow Single boot from raw disk Boot ISO to install, then reboot from disk
Time to first curl ~15 minutes ~45-60 minutes
Configuration Containerfile only Containerfile + cloud-config YAML
New concepts 1-2 beyond normal containers 5-8 beyond normal containers
Kubernetes built-in No (bring your own) Yes (k3s/k0s included)
Fleet management No Yes (P2P, QR code, operator)
Atomic upgrades bootc upgrade kairos-agent upgrade
Rollback ostree-based A/B; bootc rollback Active/passive/recovery partitions
Immutable rootfs ostree: /usr immutable, /etc 3-way merge, /var writable Partition-based: squashfs rootfs, /oem + /usr/local writable
Official QEMU docs Not documented Reference page with build script
Hello-world example None in repo None focused on httpd

New user experience: honest impressions

bootc

What went well:

  • The mental model is dead simple: "it's a container, but it boots." If you know Containerfiles, you know 90% of what you need.
  • dnf install httpd && systemctl enable httpd is exactly what a sysadmin expects. No new abstractions.
  • One podman run command produces a bootable disk. No intermediate ISOs, no two-phase install.
  • The docs are accurate, if reference-heavy rather than tutorial-oriented.

What tripped me up:

  • The README points to ADOPTERS.md rather than a getting-started guide. The actual user docs live at docs.fedoraproject.org/en-US/bootc/, a separate site not linked prominently.
  • Zero example Containerfiles in the repo. You synthesize the workflow from several doc pages (bootc-install.md, building/guidance.md, building/bootc-runtime.md, building/users-and-groups.md).
  • No QEMU documentation. After creating disk.raw, you're on your own for the qemu-system-x86_64 invocation.
  • Forgetting to set a root password or SSH key means you boot into a system you can't log into. The docs warn about this but it's easy to miss.
  • The --via-loopback flow requires root (sudo podman run --privileged).
  • /var semantics are subtle: content placed in /var/www/html/ during the build is present on first install but won't update on subsequent bootc upgrade operations. For content that should track the image, use /usr/share/httpd/ with a symlink.
  • SELinux labels: chcon doesn't work in container builds. Use semanage fcontext instead.

Kairos

What went well:

  • Polished landing page and clear quickstart guide at kairos.io.
  • The "Build Raw images with QEMU" reference page gives you a complete shell script.
  • Cloud-config is powerful — set up users, networking, k3s, partitions, and run arbitrary commands at different boot stages from a single YAML file.
  • Built-in Kubernetes is genuinely convenient if that's your goal. After install, kubectl get nodes just works.
  • Multiple install paths (QR code, WebUI, netboot, P2P) are impressive for fleet scenarios.
  • Config validation: kairos validate ./cloud-config.yaml catches errors before deployment.

What tripped me up:

  • The quickstart uses VirtualBox, not QEMU. QEMU instructions are in a separate reference page and assume familiarity with the Kairos installation model.
  • Hadron (the default flavor) has no package manager. Installing Apache — a completely standard Linux service — requires either a static binary or switching to a BYOI flavor. This is a jarring surprise.
  • BYOI flavors (Ubuntu, openSUSE) no longer ship prebuilt artifacts. You build everything yourself with kairos-init and AuroraBoot. Some docs still reference old artifact names.
  • The "kairosify" step (kairos-init) is opaque. It installs the Kairos agent, immucore, dracut modules, and GRUB config into your image. If it fails, debugging is difficult.
  • Cloud-config uses yip, not cloud-init. The syntax is similar but not identical. If you've used cloud-init before, the differences will bite you (e.g., stages: blocks, no runcmd at top level in the standard way).
  • Two-phase QEMU installation (boot ISO to install, then boot from disk) is inherently more complex than bootc's single-step approach.
  • The cloud-init sidecar ISO (mkisofs -volid cidata) is an extra step that's easy to get wrong.
  • AuroraBoot flag naming (artifact_version, release_version, flavor, flavor_release, repository) is confusing — it's hard to know which combination is needed.
  • Documentation defaults to the "Next" (development) version with a warning banner. Getting to stable docs requires clicking through.

Tips and gotchas

For bootc users

  1. Always set a root password or SSH key in your Containerfile for testing. Remove it before production.
  2. Use --generic-image with bootc install to-disk. Without it, the image may be tied to the build host's identity. (Note: --generic-image is auto-enabled when using --via-loopback, but it's good practice to be explicit.)
  3. Content in /var/ won't update on upgrades. Put web content under /usr/share/httpd/ and symlink, or use a bind mount.
  4. SELinux labels matter. Use semanage fcontext in the Containerfile (not chcon, which doesn't persist in container builds).
  5. The filesystem type must be known. If your base image doesn't set a default, pass --filesystem xfs (or ext4/btrfs) to bootc install to-disk.
  6. For qcow2 output, use bootc-image-builder --type qcow2 rather than raw disk + manual conversion.
  7. Port forwarding in QEMU: use -nic user,hostfwd=tcp::8080-:80 to expose guest port 80 on host port 8080.

For Kairos users

  1. Start with a BYOI flavor if you need standard packages. Hadron is optimized for Kubernetes appliances, not general-purpose servers.
  2. Test cloud-config in a container first:
    docker run -ti -v $PWD:/test \
      --entrypoint /usr/bin/kairos-agent --rm \
      quay.io/kairos/hadron:v0.0.4-core-amd64-generic-v4.0.1 \
      run-stage --cloud-init-paths /test boot
  3. Set install.poweroff: true and install.auto: true in cloud-config for unattended QEMU installs. Without these, the installer waits for interactive input.
  4. Cloud-config goes in a sidecar ISO, not baked into the main ISO (unless you rebuild with AuroraBoot). Use mkisofs -volid cidata.
  5. Eject the ISO after installation. In QEMU, remove the -drive flags for the ISO and ci.iso on the second boot.
  6. Kairos uses yip, not cloud-init. Don't expect full cloud-init compatibility. Check the configuration reference.
  7. If you don't need Kubernetes, omit --provider from kairos-init to get a "core" image without k3s/k0s.
  8. The config file lives at /oem/90_custom.yaml after installation. SSH in and edit for testing (changes apply on reboot).
  9. Port forwarding: not documented in Kairos' QEMU guide. Use -nic user,hostfwd=tcp::8080-:80 the same as any QEMU setup.

Project backing

bootc is part of the Fedora/Red Hat ecosystem, with base images from CentOS Stream and Fedora. It is developed in Rust, uses ostree under the hood, and is on track for broader adoption in the RHEL/CentOS family.

Kairos is a CNCF Sandbox project, backed commercially by Spectro Cloud (who also develops the Hadron base and the Palette edge management platform). It has an active community and a regular release cadence.

When to choose which

Choose bootc if:

  • You want the simplest path from "container image" to "bootable VM."
  • Your team already knows Containerfiles and Linux administration.
  • You don't need built-in Kubernetes or fleet management.
  • You want standard distro package managers (dnf, apt).
  • Your use case is servers, VMs, or cloud instances.

Choose Kairos if:

  • You need integrated Kubernetes (k3s/k0s) out of the box.
  • You're deploying fleets of edge devices and need P2P provisioning, QR code installs, or a management operator.
  • You want active/passive/recovery boot modes with encrypted partitions.
  • You need a built-in declarative config system (cloud-config/yip).
  • You're willing to invest in learning a more complex system for more powerful fleet-scale features.

Conclusion

For the specific task of "get Apache serving hello-world in QEMU," bootc is substantially simpler. The workflow is: write a Containerfile, build, create a disk image, boot. Four steps, one new concept beyond standard containers.

Kairos requires at least twice as many steps and introduces several new abstractions (kairosification, cloud-config stages, AuroraBoot, two-phase install). Its strength is in the platform it provides: integrated Kubernetes, fleet management, and multiple provisioning methods. But those capabilities come with complexity that's hard to justify for a simple "run Apache in a VM" task.

Both projects share the fundamental insight that container images are a great delivery mechanism for operating systems. They differ in where they draw the line between "just make it boot" (bootc) and "make it a complete edge platform" (Kairos).


Assisted-by: OpenCode (Claude Opus 4)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment