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.
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:
FROMstarts from the Fedora bootc base imagednf installadds nginx and the guest agent to the OS itself- the first
COPYreplaces the nginx default page - the second
COPYadds the registry drop-in systemctl enableruns at build time, so nginx starts on first bootbootc container lintchecks 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:

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 withsudo 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.