kde-redhat.org · all guides

PillarServer · RHEL family PublishedOctober 5, 2026 AuthorElton

Image Mode and bootc Hands-On: Build a RHEL-Compatible Server from a Container Image

A bootc tutorial for image mode: build a CentOS Stream 10 or RHEL server from a Containerfile, boot it as a VM, then upgrade and roll it back.

TL;DR

In image mode the operating system is a container image. You write a Containerfile FROM a bootc base image, build and push it with podman, turn it into a bootable disk with bootc-image-builder, and from then on the server updates by pulling a newer image, with an A/B rollback slot. CentOS Stream 10 is the no-subscription way to learn it; swap the FROM line for registry.redhat.io/rhel10/rhel-bootc and the same workflow applies to RHEL 10 and RHEL 9.6+.

# 1. Build and push the OS image (rootful: the disk builder reads root's storage)
$ sudo podman build -t quay.io/YOURUSER/hello-bootc:latest .
$ sudo podman push quay.io/YOURUSER/hello-bootc:latest

# 2. Turn it into a qcow2 VM disk
$ sudo podman run --rm -it --privileged --pull=newer \
    --security-opt label=type:unconfined_t \
    -v ./config.toml:/config.toml:ro -v ./output:/output \
    -v /var/lib/containers/storage:/var/lib/containers/storage \
    quay.io/centos-bootc/bootc-image-builder:latest \
    --type qcow2 --config /config.toml \
    quay.io/YOURUSER/hello-bootc:latest
$ ls output/qcow2/
disk.qcow2

# 3. Day 2, on the booted server
$ sudo bootc status      # which image am I running?
$ sudo bootc upgrade     # stage the newer image for next boot
$ sudo bootc rollback    # queue the previous deployment instead
StatusVerified against official docs. NOT executed on a live VM for this article. TargetsCentOS Stream 10 (centos-bootc:stream10), RHEL 10 and RHEL 9.6+ (rhel-bootc) Kerneln/a (nothing was booted for this article) Checked on2026-10-05 EnvironmentDocumentation cross-check only: Red Hat image mode docs (RHEL 9 and 10), bootc upstream man pages, osbuild docs, bootc source for output formats

I want to be upfront about that plate, because it breaks the usual rule of this site. I did not have a RHEL-family VM with bootc available when I wrote this, so every command below was checked against the official Red Hat documentation and the upstream bootc and osbuild docs on the date above, cross-referencing at least two sources each, and the expected outputs come from those docs and from the bootc source code. Digests, dates and version strings on your system will differ. When I run this end to end on real hardware, this plate gets updated with the kernel and the date.

What image mode actually changes

On a classic ("package mode") RHEL server, the OS is the sum of every dnf transaction anyone ever ran on it. Two servers built from the same kickstart drift apart within a month. Image mode flips that: you describe the OS in a Containerfile, build it in CI like any app image, and the server boots that exact image. The tool doing the work on the host is bootc, which takes an OCI image and deploys it as the root filesystem, using ostree under the hood.

Three directories behave differently from what you are used to, and almost every image mode surprise traces back to them (the details are in the bootc filesystem docs):

Updates are A/B: bootc upgrade stages the new image as a second deployment, the next reboot switches to it, and the old one stays around as the rollback target. Red Hat announced image mode as generally available, included in all RHEL subscriptions, supported on RHEL 9.6 and 10 and newer. CentOS Stream publishes free bootc images on quay.io/centos-bootc/centos-bootc (tags stream9 and stream10), and Fedora has quay.io/fedora/fedora-bootc. AlmaLinux publishes bootc images too, labeled experimental on their side; if you are still choosing between the rebuilds, my Rocky vs AlmaLinux comparison covers the bigger picture.

What you need for this bootc tutorial

Everything here is podman. If you set up Docker on a server with my Docker on AlmaLinux and Rocky 10 guide, that is fine for app workloads, but the bootc tooling expects podman and its storage at /var/lib/containers/storage.

Step 1: Write the Containerfile

Make a working directory with three files. The OS image first:

$ mkdir hello-bootc && cd hello-bootc
$ cat Containerfile
FROM quay.io/centos-bootc/centos-bootc:stream10

RUN dnf -y install httpd firewalld && dnf clean all

RUN systemctl enable httpd firewalld && \
    firewall-offline-cmd --add-service=http

COPY index.html /var/www/html/index.html

RUN bootc container lint

Line by line: the base image already carries the kernel, systemd and bootc. systemctl enable works at build time because it only creates symlinks. firewall-cmd would fail here because it talks to the running daemon over D-Bus, so the build uses firewall-offline-cmd, which writes the same zone configuration into /etc/firewalld without a daemon. The firewall stays on and you open exactly the service you need, which is the whole point (the logic of zones is the same one I use on package-mode servers). The last line runs bootc's built-in static checks and fails the build on fatal problems.

Then the web page and the login config:

$ cat index.html
<h1>hello-bootc v1</h1>
$ cat config.toml
[[customizations.user]]
name = "admin"
password = "change-me-on-first-login"
key = "ssh-ed25519 AAAA...your-public-key... you@laptop"
groups = ["wheel"]

The user goes in config.toml, which bootc-image-builder applies when it creates the disk, and not in a RUN useradd. Human users baked into the image cause UID/GID drift between image versions, and the bootc docs steer you away from it. The base image has no default credentials, so without this file you get a VM you cannot log into.

Yes, I copied index.html into /var. Red Hat's own quick start does the same, it works for the first boot, and it sets up the most important lesson of this article in step 5.

Step 2: Build and push the image

$ sudo podman build -t quay.io/YOURUSER/hello-bootc:latest .
STEP 1/5: FROM quay.io/centos-bootc/centos-bootc:stream10
...
STEP 5/5: RUN bootc container lint
Checks passed: <n>
Warnings: <n>
COMMIT quay.io/YOURUSER/hello-bootc:latest
--> 3f1c...
Successfully tagged quay.io/YOURUSER/hello-bootc:latest

The lint counts depend on your bootc version. Expect warnings here, not errors: the var-tmpfiles lint flags content in /var that has no matching tmpfiles.d entry (that is our index.html), and var-log may flag leftover dnf logs. Warnings print and let the build pass; add --fatal-warnings to the lint line once your image is clean and you want CI to enforce it.

$ sudo podman login quay.io
Login Succeeded!
$ sudo podman push quay.io/YOURUSER/hello-bootc:latest
Getting image source signatures
Copying blob ...
Writing manifest to image destination

New quay.io repositories are private by default. For this lab, set the repository to public in the quay.io web UI, or read the second item of "If this fails" before step 5.

Step 3: Turn the image into a VM disk

$ mkdir -p output
$ sudo podman run --rm -it --privileged --pull=newer \
    --security-opt label=type:unconfined_t \
    -v ./config.toml:/config.toml:ro \
    -v ./output:/output \
    -v /var/lib/containers/storage:/var/lib/containers/storage \
    quay.io/centos-bootc/bootc-image-builder:latest \
    --type qcow2 --config /config.toml \
    quay.io/YOURUSER/hello-bootc:latest
$ ls output/qcow2/
disk.qcow2

A word on --security-opt label=type:unconfined_t, because someone will ask: that is not "disabling SELinux". The host stays enforcing; this one privileged container runs in an unconfined SELinux domain because its job is building filesystems and writing labels, which the default container domain is rightly not allowed to do. The disk it produces boots with SELinux enforcing.

Use the registry reference (not localhost/...) as the input. The installed system records that reference as its update source, so this is what lets bootc upgrade find new versions later.

2026 note on tooling: the standalone bootc-image-builder has been folded into the unified image-builder project. Per the osbuild deprecation notice, the bootc-image-builder container keeps working through RHEL 10's lifecycle (from RHEL 9.9/10.3 it becomes a compatibility wrapper around image-builder), and RHEL 11 will ship only image-builder. On RHEL 10.2 and 9.8 you can already run image-builder build qcow2 --bootc-ref=..., as described in Red Hat's image-builder 10.2/9.8 post. I use the container here because it is the invocation in the current RHEL 9 and 10 docs and it runs on any distro with podman.

Step 4: Boot it and look around

Copy the disk into libvirt's image directory first. Files there get the virt_image_t label libvirt expects, and the directory is reachable by the unprivileged QEMU process; pointing libvirt at a qcow2 inside your home directory is the classic source of permission errors and AVC denials.

$ sudo cp output/qcow2/disk.qcow2 /var/lib/libvirt/images/hello-bootc.qcow2
$ sudo virt-install --name hello-bootc --memory 4096 --vcpus 2 \
    --disk /var/lib/libvirt/images/hello-bootc.qcow2,format=qcow2 \
    --import --os-variant linux2022 --noautoconsole
Starting install...
Creating domain...
Domain creation completed.
$ sudo virsh domifaddr hello-bootc
 Name       MAC address          Protocol     Address
-------------------------------------------------------------------------------
 vnet0      52:54:00:aa:bb:cc    ipv4         192.168.122.57/24
$ curl -s http://192.168.122.57/
<h1>hello-bootc v1</h1>

Give it a minute after creation before domifaddr shows an address. Now log in and ask bootc what it is running:

$ ssh [email protected]
[admin@localhost ~]$ sudo bootc status
● Booted image: quay.io/YOURUSER/hello-bootc:latest
      Digest: sha256:736b...3c34 (amd64)
     Version: stream10.<date>.0

The version string is inherited from the CentOS base image label unless you set your own. Pipe the command to a file or use --format=json and you get a structured object instead, which is what you want for monitoring.

Step 5: Ship v2 and upgrade

Here is the lesson I promised. Make three changes on the build host: change index.html to say v2, add a package, and add an httpd config file under /etc:

$ echo '<h1>hello-bootc v2</h1>' > index.html
$ echo 'ServerTokens Prod' > hardening.conf
$ cat Containerfile
FROM quay.io/centos-bootc/centos-bootc:stream10

RUN dnf -y install httpd firewalld tmux && dnf clean all

RUN systemctl enable httpd firewalld && \
    firewall-offline-cmd --add-service=http

COPY hardening.conf /etc/httpd/conf.d/hardening.conf
COPY index.html /var/www/html/index.html

RUN bootc container lint
$ sudo podman build -t quay.io/YOURUSER/hello-bootc:latest . && \
  sudo podman push quay.io/YOURUSER/hello-bootc:latest
STEP 1/6: FROM quay.io/centos-bootc/centos-bootc:stream10
...
STEP 6/6: RUN bootc container lint
Checks passed: <n>
Warnings: <n>
COMMIT quay.io/YOURUSER/hello-bootc:latest
Successfully tagged quay.io/YOURUSER/hello-bootc:latest
Getting image source signatures
Copying blob ...
Writing manifest to image destination

On the server, stage the update and reboot into it:

[admin@localhost ~]$ sudo bootc upgrade
Queued for next boot: quay.io/YOURUSER/hello-bootc:latest
  Version: stream10.<date>.0
  Digest: sha256:16dc...7566
[admin@localhost ~]$ sudo systemctl reboot

Before rebooting, bootc status shows the new image as Staged image above the booted one. sudo bootc upgrade --apply does the stage and the reboot in one go. After the reboot, check all three changes from the build host:

$ ssh [email protected] rpm -q tmux
tmux-3.x-x.el10.x86_64
$ curl -sI http://192.168.122.57/ | grep -i '^server'
Server: Apache
$ curl -s http://192.168.122.57/
<h1>hello-bootc v1</h1>

The package arrived (it lives in /usr). The new file in /etc arrived through the 3-way merge, so ServerTokens Prod now hides the version. The page still says v1, because /var content is only seeded on first install. That is exactly what the var-tmpfiles lint warning was telling you in step 2.

The fix is a design rule, not a command: /var holds state the machine creates (databases, uploads, logs); anything that ships with the image goes under /usr, with the service pointed at it, and directories the app needs in /var are created at boot by tmpfiles.d or by StateDirectory= in the unit. If you serve web content from a new path, check its label with ls -Z and give it the right type with semanage fcontext in your build, the same way you would on a package-mode box.

Step 6: Roll back

[admin@localhost ~]$ sudo bootc rollback
Next boot: rollback deployment
[admin@localhost ~]$ sudo systemctl reboot

$ ssh [email protected] rpm -q tmux
package tmux is not installed

bootc rollback only reorders the boot entries; it does not reboot unless you add --apply, and it discards any staged update. One catch from the man page: edits you made in /etc while running v2 do not follow you back, because rollback reuses the existing v1 deployment instead of creating a new one. If you need to move to a specific older image with the /etc merge, use bootc switch to a pinned tag or digest instead.

The default that will surprise you in production: auto-updates reboot

bootc images ship bootc-fetch-apply-updates.timer. Its service checks the registry, downloads a newer image if there is one, and reboots into it. Red Hat's quick start says it plainly: automatic updates are on by default. That is great for a fleet of edge boxes and terrible for a database server someone forgot about.

[admin@localhost ~]$ systemctl list-timers bootc-fetch-apply-updates.timer
NEXT                        LEFT     LAST  PASSED  UNIT                             ACTIVATES
Tue 2026-10-06 03:12:44 UTC 13h left -     -       bootc-fetch-apply-updates.timer  bootc-fetch-apply-updates.service

Your times will differ. You have three sane options: keep it and control what lands by controlling which tag you push; override the schedule with a drop-in in /usr/lib/systemd/system/bootc-fetch-apply-updates.timer.d/ shipped in the image (say, Sunday 04:00); or turn it off in the image with RUN systemctl mask bootc-fetch-apply-updates.timer and run bootc upgrade from your own maintenance tooling. Pick one deliberately.

Other ways in: bare metal and existing servers

The qcow2 route is the lab route. For a physical box, bootc can install itself to a disk straight from the image (this wipes the target disk; triple-check the device name with lsblk):

# podman run --rm --privileged --pid=host --ipc=host \
    -v /var/lib/containers:/var/lib/containers -v /dev:/dev \
    --security-opt label=type:unconfined_t \
    quay.io/YOURUSER/hello-bootc:latest \
    bootc install to-disk --wipe --filesystem xfs /dev/sdX
Installing to /dev/sdX
Deploying container image...done
Installing bootloader via bootupd
Installed: grub.cfg
Installation complete!

That is the invocation from the upstream install docs, with --wipe and --filesystem from the bootc-install-to-disk man page. The lines above come from bootc's own bootloader-install output (upstream example, trimmed of debug noise), not a capture from this article; your device name, boot method and timing will differ. When it finishes, boot from that disk. Note that config.toml does not apply here: mount an authorized_keys file into the container (for example -v ./authorized_keys:/keys:ro) and add --root-ssh-authorized-keys /keys, or you will have a machine nobody can log into.

The third path is converting a running server in place:

# podman run --rm --privileged -v /dev:/dev \
    -v /var/lib/containers:/var/lib/containers -v /:/target \
    --pid=host --security-opt label=type:unconfined_t \
    quay.io/YOURUSER/hello-bootc:latest \
    bootc install to-existing-root \
    --root-ssh-authorized-keys /target/root/.ssh/authorized_keys
Installing to existing root at /target
Deploying container image...done
Installing bootloader via bootupd
Installed: grub.cfg
Installation complete!
# systemctl reboot
# the SSH session drops here as the box reboots into the new root; that is expected

The install output is the same upstream bootupd flow as the to-disk path above (trimmed example, not a live capture). After the reboot the old root filesystem is still reachable under /sysroot, and nothing from the old /etc is migrated for you. This is the one path I would only ever try on a machine I can throw away and reach through an out-of-band console, because a mistake means a box that does not come back on the network. Cloud images also usually need cloud-init added to your Containerfile so the provider can inject network and keys.

Disclosure: the Vultr link below is a referral. You get trial credit, and this site earns a commission if you become a paying customer.

If you do not have a spare physical machine, a small cloud VM running a RHEL-family image is the natural sandbox for this, and Vultr's trial credit covers a few hours of breaking things. To be clear: I have not run to-existing-root on Vultr for this article, so treat that as an experiment, use their web console as your safety net, and destroy the instance when you are done.

Doing the same on RHEL

Three lines change. The base image becomes FROM registry.redhat.io/rhel10/rhel-bootc:latest (or registry.redhat.io/rhel9/rhel-bootc for 9.6+), the builder becomes registry.redhat.io/rhel10/bootc-image-builder:latest, and you log in first with sudo podman login registry.redhat.io. Build on a registered RHEL host so the dnf install inside your Containerfile can reach the RHEL repositories. The Red Hat docs for creating disk images and deploying them use the same commands as above. On Fedora, use quay.io/fedora/fedora-bootc and pass --rootfs xfs (or btrfs, ext4) to the builder, because Fedora's base images do not define a default root filesystem.

If this fails

1. bootc-image-builder cannot find your image. You built it rootless, so it lives in your user's storage, while the builder only sees root's. Check where it is:

$ sudo podman images quay.io/YOURUSER/hello-bootc
REPOSITORY                     TAG     IMAGE ID      CREATED        SIZE

An empty list (header only) means root does not have it. Rebuild with sudo podman build, or copy it over with podman image scp.

2. bootc upgrade fails with an authentication or "unauthorized" error. Your quay.io repository is private and the server has no pull secret. Either make the repository public for the lab, or give the host a pull secret: bootc reads /etc/ostree/auth.json (same format as podman's auth.json). Verify it exists and is not world-readable:

[admin@localhost ~]$ sudo ls -l /etc/ostree/auth.json
-rw-------. 1 root root 142 Oct  5 10:02 /etc/ostree/auth.json

3. Your content returns 403, or your change never shows up. If it never shows up and it lives in /var, that is the seed-once rule from step 5, not a bug. If it is a 403 from a custom path, look for the denial before touching anything else:

[admin@localhost ~]$ sudo ausearch -m AVC -ts recent
type=AVC msg=audit(...): avc:  denied  { read } for  pid=... comm="httpd" name="index.html" ... tcontext=system_u:object_r:var_t:s0 tclass=file permissive=0

A tcontext like var_t or usr_t on web content means the path has no httpd file context rule. Add one with semanage fcontext -a -t httpd_sys_content_t '/your/path(/.*)?' in the Containerfile and rebuild. Do not reach for setenforce 0; the denial is telling you exactly which label is missing.

Where this leaves you

You now have the full loop: Containerfile, image, disk, booted server, staged upgrade, rollback. The mental shift is that the server stops being something you log into and fix; the Containerfile is the server, and the registry tag is the change ticket. My honest take: image mode is a great fit for fleets of identical boxes (edge, appliances, cluster nodes, CI runners), and still more friction than it is worth for a single pet server you tweak by hand every week. Start with the VM above, break it, roll it back, and you will know quickly which kind of admin you are.

Primary sources, checked 2026-10-05: Red Hat, image mode GA · bootc install docs · bootc filesystem docs · bootc-upgrade(8) · bootc-switch(8) · bootc-container-lint(8) · bootc-fetch-apply-updates.service(5) · osbuild deprecation notice · Red Hat image mode quick start.