Containers

Build a Custom Fedora bootc Image and Switch a Host to It

The first VM I booted from my custom Fedora bootc image let me log in over SSH and then refused every sudo. The image was fine. The problem was the blueprint: a user with only an SSH key gets into the wheel group with no password, and sudo still wants one. That’s the kind of thing you only find by booting the result, so this post walks the whole loop on real machines, mistakes included.

Original content from computingforgeeks.com - post 175934

You’ll write a Containerfile on top of the Fedora bootc base image, build and push it to a local registry, turn it into a qcow2 disk with image-builder, boot that on Proxmox, then ship a second version with bootc switch and undo it with bootc rollback. I ran all of it in October 2026 on a Fedora 45 VM called box1, which built the images and hosted the registry, and a second VM, box2, which booted from them.

Writing the Containerfile

A bootc image is an ordinary container image that also carries a kernel and boots. You build it with Podman like any other image, and whatever you install in the Containerfile becomes the OS of every machine that runs it. Mine adds nginx with a custom page, the QEMU guest agent so Proxmox can see the VM, and a registry drop-in I’ll explain in a minute. Start in an empty directory on the build machine:

sudo dnf install -y podman
mkdir -p ~/web-bootc && cd ~/web-bootc
vim Containerfile

I built from exactly this file:

FROM quay.io/fedora/fedora-bootc:45
RUN dnf -y install nginx qemu-guest-agent && dnf clean all
COPY index.html /usr/share/nginx/html/index.html
COPY 50-lab-registry.conf /etc/containers/registries.conf.d/50-lab-registry.conf
RUN systemctl enable nginx
RUN bootc container lint

Line by line, it does six things:

  • FROM starts from the Fedora bootc base image
  • dnf install adds nginx and the guest agent to the OS itself
  • the first COPY replaces the nginx default page
  • the second COPY adds the registry drop-in
  • systemctl enable runs at build time, so nginx starts on first boot
  • bootc container lint checks the result before you ship it

The page is one line, so echo is enough:

echo '<h1>web-bootc v1</h1>' > index.html

Why the registry file? The machines that boot this image will pull their updates from the same registry I push to, and my lab registry is plain HTTP. Without this drop-in, bootc on the booted host would refuse to talk to it. Baking the setting into the image means every host gets it from the first boot. Replace the address with your build machine’s:

vim 50-lab-registry.conf

Three lines, marking that one registry as insecure:

[[registry]]
location = "10.0.1.148:5000"
insecure = true

That’s fine for a lab on one subnet. The registry keeps its images inside the container, so podman rm registry would delete every image your hosts follow; for anything you keep, add a volume such as -v registry-data:/var/lib/registry to the podman run line below. For anything else, put a TLS certificate on the registry, as in our Podman registry with Let’s Encrypt setup, or use a hosted one, and drop this file.

Building and pushing the image

I build as root, because image-builder reads the image from the container storage of the user running it, and it has to run as root. Start a registry on the same box first. --restart=always only brings the container back after a reboot if podman-restart.service is enabled, and it was disabled on box1, so the second line turns it on. REG must match the location in 50-lab-registry.conf. box1 had no firewalld running; on Fedora Server, open the port with sudo firewall-cmd --add-port=5000/tcp --permanent and sudo firewall-cmd --reload so other machines can reach it:

sudo podman run -d --name registry -p 5000:5000 --restart=always docker.io/library/registry:2
sudo systemctl enable podman-restart.service
REG=10.0.1.148:5000
time sudo podman build -t ${REG}/web-bootc:v1 .
sudo podman push --tls-verify=false ${REG}/web-bootc:v1

The last step in the Containerfile, bootc container lint, runs inside the build and checks the image for things that break on a booted system. Mine passed with three warnings, all about leftovers in /run and /var from the dnf run:

Lint warning: nonempty-run-tmp: Found content in runtime-only directories (/run, /tmp):
  /run/dnf

Lint warning: var-log: Found non-empty logfiles:
  /var/log/dnf5.log

Lint warning: var-tmpfiles: Found content in /var missing systemd tmpfiles.d entries:
  d /var/cache/libdnf5 0755 root root - -
  d /var/lib/dnf/repos 0755 root root - -
  d /var/lib/dnf/repos/fedora-cacf678974f18610 0755 root root - -
  d /var/lib/dnf/repos/updates-6f655cd2c39e9765 0755 root root - -
  d /var/log/qemu-ga 0755 root root - -
Found non-directory/non-symlink files in /var:
  var/lib/dnf/repos/fedora-cacf678974f18610/countme
  var/lib/dnf/repos/updates-6f655cd2c39e9765/countme
  var/cache/ldconfig/aux-cache
  var/log/dnf5.log

Checks passed: 11
Checks skipped: 1
Warnings: 3

Warnings do not fail the build, and none of these stopped the image from booting, but they’re worth reading. /var in a bootc image is only copied on the first install, so anything you put there in the Containerfile won’t reach hosts that are already running. Treat it as state, not as part of the image. The first build took 3 minutes 9 seconds, including the pull of the 2 GB base image:

sudo podman images --format "{{.Repository}}:{{.Tag}} {{.Size}}" | grep -E "web-bootc|fedora-bootc"

My additions put only 40 MB on top of the base:

10.0.1.148:5000/web-bootc:v1 2.03 GB
quay.io/fedora/fedora-bootc:45 1.99 GB

Turning the image into a disk

A container image can’t boot a fresh VM by itself, so something has to lay it onto a disk with partitions and a bootloader. That tool used to be the bootc-image-builder container. Its code now lives in image-builder, which Fedora packages, and the migration notes recommend calling it directly:

sudo dnf install -y image-builder
image-builder --version

Fedora ships it with osbuild underneath. The deprecation note is harmless:

Flag --version has been deprecated, use "image-builder version" instead
image-builder:
  version: 84.0.0
  commit: unknown
  dependencies:
    osbuild: "193"

The blueprint is where you add a login, and it’s where my first attempt went wrong. A user with only key set boots fine and accepts SSH, but has no password, so sudo fails for everyone in wheel. Give the user a password hash too, along with a key from the machine you’ll log in from. On box1, create the key if you don’t have one, print it, and generate the hash:

[ -f ~/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519 -q
cat ~/.ssh/id_ed25519.pub
openssl passwd -6

Then write the blueprint with your hash and the public key of the machine you’ll SSH from:

vim blueprint.toml

Mine, with the hash left out:

[[customizations.user]]
name = "philip"
password = "<SHA-512 HASH FROM openssl passwd -6>"
key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILO6iArv1CYhWan++7zBdrDKYLK+qssspjcSxClKCThc philip@box1"
groups = ["wheel"]

I also tried to set the hostname here and got a clear refusal, so set it after the first boot instead:

error: Warnings during manifest creation:
blueprint validation failed for image type "qcow2": customizations.hostname: not supported

Build the qcow2. --bootc-ref names the image to install, and that same name becomes the reference the booted host updates from, which is why I give it the registry address and not a local tag:

mkdir -p output
time sudo image-builder build --blueprint ./blueprint.toml --output-dir ./output --bootc-ref ${REG}/web-bootc:v1 --bootc-default-fs xfs qcow2

It finished in 2 minutes 45 seconds and left one 1.2 GB file:

Image build successful: output/bootc-fedora-45-qcow2-x86_64.qcow2

Booting it on Proxmox

Copy the qcow2 to a directory on the Proxmox host, renaming it as you go, for example scp output/bootc-fedora-45-qcow2-x86_64.qcow2 root@pve01:/zfs-pool/web-bootc-v1.qcow2, and import it as the disk of a new VM, the same way the cloud-image template guide imports distro images. I used a SeaBIOS VM with the agent enabled, because the image carries qemu-guest-agent; adjust the storage name and path to yours:

qm create 386 --name box2 --memory 4096 --cores 2 --cpu host --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci --agent enabled=1
qm set 386 --scsi0 "zfs-pool:0,import-from=/zfs-pool/web-bootc-v1.qcow2"
qm resize 386 scsi0 +10G
qm set 386 --boot order=scsi0
qm start 386

The guest agent reported an address within a minute; on the Proxmox host, qm guest cmd 386 network-get-interfaces lists it. I logged in from box1 with ssh philip@ and that address, using the key from the blueprint. Set the hostname, then look at what bootc thinks the machine is:

sudo hostnamectl hostname box2
sudo bootc status --format=yaml

The YAML is long, so this is its start. What matters is the image reference, which is the registry tag I built with:

apiVersion: org.containers.bootc/v1
kind: BootcHost
metadata:
  name: host
spec:
  image:
    image: 10.0.1.148:5000/web-bootc:v1
    transport: registry
  bootOrder: default
status:
  staged: null
  booted:
    image:
      image:
        image: 10.0.1.148:5000/web-bootc:v1
        transport: registry
      version: 45.20261019.1
      timestamp: 2026-10-19T23:15:26.290593562Z
      imageDigest: sha256:1eb97d2d58f8145d0fca5a49514b3a4963c4634b73bf854d175d8f1a44938050

And the things I put in the Containerfile are all there, with nginx serving the page:

curl -s localhost/
rpm -q nginx qemu-guest-agent

Both packages came from the image, not from anything I ran on box2:

<h1>web-bootc v1</h1>
nginx-1.30.5-2.fc45.x86_64
qemu-guest-agent-11.1.2-1.fc45.x86_64

Shipping v2 with bootc switch

What makes bootc worth the setup is that changing every host comes down to a rebuild and a push. Back on box1, I added htop and changed the page:

REG=10.0.1.148:5000
sed -i 's/qemu-guest-agent \&\&/qemu-guest-agent htop \&\&/' Containerfile
echo '<h1>web-bootc v2</h1>' > index.html
time sudo podman build -q -t ${REG}/web-bootc:v2 .
sudo podman push -q --tls-verify=false ${REG}/web-bootc:v2
curl -s http://${REG}/v2/web-bootc/tags/list

With the base image cached, the rebuild took 48 seconds, and the registry now holds both tags:

{"name":"web-bootc","tags":["v1","v2"]}

I pushed v2 as a new tag rather than overwriting v1, so the host has to be told to follow it. That’s what bootc switch does. Run it on box2:

time sudo bootc switch 10.0.1.148:5000/web-bootc:v2
sudo bootc status --format=humanreadable

The layer count surprised me. Only 4 of 70 layers had to come over the network, 20 MB in total, because the base layers were identical:

layers already present: 66; layers needed: 4 (20.0 MB)
Deploying...done (3 seconds)
Queued for next boot: 10.0.1.148:5000/web-bootc:v2
  Version: 45.20261019.1
  Digest: sha256:9b363b4408ed0080f6d5bdb65ccacf2741ac6f016b6043997ca4dff75b164e55

real	0m9.573s
user	0m3.707s
sys	0m4.736s
  Staged image: 10.0.1.148:5000/web-bootc:v2
        Digest: sha256:9b363b4408ed0080f6d5bdb65ccacf2741ac6f016b6043997ca4dff75b164e55 (amd64)
       Version: 45.20261019.1 (2026-10-20T00:05:17Z)

● Booted image: 10.0.1.148:5000/web-bootc:v1
        Digest: sha256:1eb97d2d58f8145d0fca5a49514b3a4963c4634b73bf854d175d8f1a44938050 (amd64)
       Version: 45.20261019.1 (2026-10-19T23:15:26Z)

On box2 the staged and booted images sit one above the other, and the bullet marks the one that is running:

bootc switch staging the v2 custom image on Fedora 45 with the v1 image still booted

Nothing changes on the running system yet: v2 is staged and waits for the next boot. That next boot may not be yours to choose, though. Fedora’s bootc image enables an update timer by default, and on box2 it was waiting to run:

systemctl is-enabled bootc-fetch-apply-updates.timer
systemctl cat bootc-fetch-apply-updates.timer | grep -E "^(OnBootSec|OnUnitInactiveSec|RandomizedDelaySec)"
systemctl cat bootc-fetch-apply-updates.service | grep ExecStart

It fires an hour after boot and then every eight hours, with up to two hours of random delay, and it runs bootc upgrade --apply, which reboots into anything staged:

enabled
OnBootSec=1h
OnUnitInactiveSec=8h
RandomizedDelaySec=2h
ExecStart=/usr/bin/bootc upgrade --apply --quiet

So a staged switch lands within hours whether you reboot or not. If you want to pick the moment, disable the timer on the host; the upstream bootc docs also show disabling it in the Containerfile, which I didn’t test, and Fedora’s manual rollback page uses the same command on the host:

sudo systemctl disable --now bootc-fetch-apply-updates.timer

It confirms by removing the timer link:

Removed '/etc/systemd/system/timers.target.wants/bootc-fetch-apply-updates.timer'.

The timer’s next run was over two hours away during my tests, so it never fired; I disabled it afterwards. Reboot with sudo systemctl reboot, then check again:

sudo bootc status --format=humanreadable
curl -s localhost/
rpm -q htop

box2 now runs v2, and v1 sits one step behind as the rollback image:

● Booted image: 10.0.1.148:5000/web-bootc:v2
        Digest: sha256:9b363b4408ed0080f6d5bdb65ccacf2741ac6f016b6043997ca4dff75b164e55 (amd64)
       Version: 45.20261019.1 (2026-10-20T00:05:17Z)

  Rollback image: 10.0.1.148:5000/web-bootc:v1
          Digest: sha256:1eb97d2d58f8145d0fca5a49514b3a4963c4634b73bf854d175d8f1a44938050 (amd64)
         Version: 45.20261019.1 (2026-10-19T23:15:26Z)
<h1>web-bootc v2</h1>
htop-3.5.3-1.fc45.x86_64

If you’d rather keep one tag, push the new build over :v1 and run sudo bootc upgrade on the host instead of switch; the host keeps following the same reference and picks up whatever it now points at, and the timer above does the same on its own schedule. I didn’t run that path here, so check the bootc documentation for its exact behaviour first.

Rolling back to v1

Say v2 broke something. Going back doesn’t need the registry at all, because the previous deployment is still on disk:

time sudo bootc rollback
sudo bootc status --format=humanreadable

It took a second, but the status display afterwards looked exactly as before, which threw me:

Next boot: rollback deployment

real	0m1.208s
user	0m0.533s
sys	0m0.227s
● Booted image: 10.0.1.148:5000/web-bootc:v2
        Digest: sha256:9b363b4408ed0080f6d5bdb65ccacf2741ac6f016b6043997ca4dff75b164e55 (amd64)
       Version: 45.20261019.1 (2026-10-20T00:05:17Z)

  Rollback image: 10.0.1.148:5000/web-bootc:v1
          Digest: sha256:1eb97d2d58f8145d0fca5a49514b3a4963c4634b73bf854d175d8f1a44938050 (amd64)
         Version: 45.20261019.1 (2026-10-19T23:15:26Z)

The human-readable view doesn’t show the queued rollback. The JSON does, as "rollbackQueued": true under status, so check sudo bootc status --format=json if you need to confirm it before rebooting. After the reboot, v1 is back and htop is gone with it:

● Booted image: 10.0.1.148:5000/web-bootc:v1
        Digest: sha256:1eb97d2d58f8145d0fca5a49514b3a4963c4634b73bf854d175d8f1a44938050 (amd64)
       Version: 45.20261019.1 (2026-10-19T23:15:26Z)

  Rollback image: 10.0.1.148:5000/web-bootc:v2
          Digest: sha256:9b363b4408ed0080f6d5bdb65ccacf2741ac6f016b6043997ca4dff75b164e55 (amd64)
         Version: 45.20261019.1 (2026-10-20T00:05:17Z)
<h1>web-bootc v1</h1>
package htop is not installed

Notice the two images just swapped places. Running bootc rollback again should take you forward to v2, so treat a rollback as a toggle between the last two deployments, not an undo history. The bootc-rollback man page also warns that a running update timer may undo a rollback, which is one more reason to disable it while you investigate a bad build. Each deployment keeps its own /etc, though, so a timer you disable on the bad build is enabled again after the rollback reboot; check systemctl is-enabled bootc-fetch-apply-updates.timer once you are back on the older image.

What I’d do differently

The loop works, and after the first build it’s fast: a 48-second rebuild and a 20 MB download per host. Three things I’d change before using it for more than a lab:

  • Put the password hash in the blueprint on the first build, not the second.
  • Run the registry with TLS and drop the insecure drop-in from the image.
  • Keep one tag per channel and let the update timer apply it, so hosts follow it without a per-host switch, and push only builds you are happy to see rebooted into; if you disabled the timer earlier, turn it back on with sudo systemctl enable --now bootc-fetch-apply-updates.timer.

I’d also clean the dnf leftovers the lint flagged, mostly to keep its output readable when a real warning shows up. If you’ve used rpm-ostree on Silverblue, much of this will feel familiar; the Silverblue package management guide shows the same deployments from the rpm-ostree side, and Fedora CoreOS is the other image-based Fedora worth comparing before you pick one.

Keep reading

Enable RPM Fusion and Install Multimedia Codecs on Fedora 45 / 44 / 43 Fedora Enable RPM Fusion and Install Multimedia Codecs on Fedora 45 / 44 / 43 Best UI Applications for Managing Docker Containers Containers Best UI Applications for Managing Docker Containers Harden Fedora 45 / 44 for Desktop and Server: Complete Guide Fedora Harden Fedora 45 / 44 for Desktop and Server: Complete Guide Run Podman Quadlet Systemd Containers on Fedora 45 / 44 Containers Run Podman Quadlet Systemd Containers on Fedora 45 / 44 Install Podman Compose on Fedora 45 / 44 and Run Rootless Stacks Fedora Install Podman Compose on Fedora 45 / 44 and Run Rootless Stacks Fix “error: Metrics API not available” in Kubernetes Containers Fix “error: Metrics API not available” in Kubernetes

Leave a Comment

Press ESC to close