Fedora CoreOS has no installer you click through. A node boots once with an Ignition config, written in Butane YAML and converted to JSON, and that one file decides its users, files, services and update policy for good. Get a field wrong and the node still boots, it just is not the node you meant: our first config scheduled the Zincati reboot window in UTC, our first web container stayed dead after a single crash, and a typo in the SSH key field produced an Ignition file with no key in it at all.
This guide builds one Butane file and boots it three ways: on KVM with virt-install on the stable stream, on KVM on the next stream, and on OpenStack. We tested in October 2026 on a Fedora 45 KVM host with stable 44.20260928.3.1 and next 45.20261010.1.1, plus an OpenStack 2026.2 DevStack cloud.

Pick a Fedora CoreOS stream
FCOS ships as three streams, and a node follows the one it was installed from until you rebase it. Each stream maps to a Fedora release, which is why the version strings start with 44 or 45. These were the current builds when we pulled the stream metadata from builds.coreos.fedoraproject.org.
| Stream | Build we tested | Fedora base | Use it for |
|---|---|---|---|
stable | 44.20260928.3.1 | 44 | Anything you care about |
testing | 44.20261012.2.1 | 44 | A canary share of a fleet, to catch what stable gets next |
next | 45.20261010.1.1 | 45 | Trying the next Fedora release before stable moves to it |
Run production on stable, and keep a node or two on testing so a regression shows up there first. Fedora’s update stream documentation encourages running part of a fleet on testing and next for exactly that reason. We booted next alongside stable to see the Fedora 45 base, and the same Butane config worked unchanged on both, so you do not need a second config to try the next release. If you only run one node, stay on stable and let the stream move to the new Fedora base for you.
Install Butane, coreos-installer and KVM
On Fedora everything comes from the main repositories. butane converts the YAML, coreos-installer downloads and verifies images, and the libvirt packages run the VMs. Install them on the KVM host.
sudo dnf install -y qemu-kvm libvirt-daemon-kvm libvirt-daemon-config-network virt-install butane coreos-installer
sudo systemctl enable --now virtqemud.socket virtnetworkd.socket virtstoraged.socket
Fedora uses the modular libvirt daemons, so you enable sockets rather than a single libvirtd. Check the versions you got, because the OS variant names below depend on osinfo-db.
rpm -q butane coreos-installer virt-install qemu-kvm libvirt-daemon-kvm osinfo-db
Our Fedora 45 host had these builds, and its osinfo-db knew the fedora-coreos-stable and fedora-coreos-next OS variants.
butane-2.28.0-1.fc45.x86_64
coreos-installer-0.27.0-1.fc45.x86_64
virt-install-5.1.0-5.fc45.noarch
qemu-kvm-11.1.2-1.fc45.x86_64
libvirt-daemon-kvm-12.6.0-2.fc45.x86_64
osinfo-db-20251212-2.fc45.noarch
The default libvirt network, virbr0 on 192.168.122.0/24, came up active and set to autostart, so there was nothing else to configure. If you’re starting from a bare host, our guide to installing KVM on Fedora covers bridges and storage pools in more depth.
Write the Butane config
Keep the config in its own directory with the public key next to it. Butane’s ssh_authorized_keys_local reads the key from a file at conversion time, so the YAML never carries a pasted key and the same file works for anyone who drops their own id_ed25519.pub beside it.
mkdir -p ~/fcos && cd ~/fcos
cp ~/.ssh/id_ed25519.pub .
vi fcos01.bu
Here is the exact file we booted on every node in this guide, with only the hostname changing per node. Copy it as a starting point and change the hostname, time zone and container to suit you.
variant: fcos
version: 1.7.0
passwd:
users:
- name: core
ssh_authorized_keys_local:
- id_ed25519.pub
storage:
files:
- path: /etc/hostname
mode: 0644
contents:
inline: fcos01
- path: /etc/zincati/config.d/55-updates-strategy.toml
mode: 0644
contents:
inline: |
[updates]
strategy = "periodic"
[updates.periodic]
time_zone = "localtime"
[[updates.periodic.window]]
days = [ "Sat", "Sun" ]
start_time = "22:30"
length_minutes = 60
- path: /etc/systemd/zram-generator.conf
mode: 0644
contents:
inline: |
[zram0]
zram-size = min(ram / 2, 4096)
- path: /etc/containers/systemd/web.container
mode: 0644
contents:
inline: |
[Unit]
Description=nginx web container
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=web
Image=docker.io/library/nginx:stable
PublishPort=8080:80
[Service]
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
links:
- path: /etc/localtime
target: ../usr/share/zoneinfo/Africa/Nairobi
Three parts of that file came from mistakes we made on the first pass, and each one is easy to miss because the node still boots fine without it:
time_zone = "localtime": without it Zincati reads the window as UTCzram-generator.conf: FCOS ships the generator but no default deviceRestart=on-failure: Quadlet units default toRestart=no
The time zone line matters more than it looks. Our first node had /etc/localtime set to Africa/Nairobi and the same window, and Zincati’s log said next window at 22:30 on Sat (UTC), so reboots would have landed at 01:30 local time on Sunday. Zincati’s documentation confirms that windows are UTC unless you set time_zone. The restart policy came from a kill test: with the default, podman kill web left the unit failed for good, and the same happened on OpenStack when the first image pull hit a subnet with no DNS server and no working egress. Everything else is the usual Butane shape; the Butane config specification for FCOS lists every field.
Convert Butane to Ignition
Always convert with --strict. --files-dir . tells Butane where to find the key file, and --pretty keeps the JSON readable if you ever need to inspect it.
butane --pretty --strict --files-dir . fcos01.bu > fcos01.ign
A clean conversion prints nothing. To see why --strict is not optional, we misspelled the key field as ssh_authorised_keys_local in a copy of the file and converted it again.
warning[$.passwd.users.0.ssh_authorised_keys_local]:
--> typo.bu:6:7
|
4 | users:
5 | - name: core
6 | ssh_authorised_keys_local:
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ unused key ssh_authorised_keys_local
7 | - id_ed25519.pub
8 | storage:
|
Config produced warnings and --strict was specified
exit=1
Without --strict, the same file converted with exit code 0, printed the warning to stderr, and produced an Ignition config with no SSH key at all. A node booted from that file would run perfectly and refuse every SSH login. Ignition only runs on first boot, so the practical fix is to provision the node again; Fedora’s access recovery procedure can get you in from the console, but that is a rescue, not a workflow. Treat a Butane warning as an error every time.
Download the Fedora CoreOS image
coreos-installer download fetches the current image for a stream and platform, checks its GPG signature against the Fedora key and deletes the file if the check fails. It also prints the path of the image it wrote, so capture that in a variable instead of typing a build number that will be out of date within weeks. Put the image in /var/lib/libvirt/images rather than your home directory, because the system QEMU process can’t read your home; the troubleshooting section shows the error you’d get.
STREAM="stable"
IMAGE=$(sudo coreos-installer download -s $STREAM -p qemu -f qcow2.xz --decompress -C /var/lib/libvirt/images/ 2>/dev/null)
echo "IMAGE=$IMAGE"
The 872 MiB image took 58 seconds on our connection. An empty IMAGE means the download or the signature check failed, so stop there. Ours printed:
IMAGE=/var/lib/libvirt/images/fedora-coreos-44.20260928.3.1-qemu.x86_64.qcow2
Leave the downloaded file alone afterwards. Each VM below gets its own small disk that uses this image as a read-only backing file, so one download serves any number of nodes. If you open a new shell later, recover the path with IMAGE=$(ls -t /var/lib/libvirt/images/fedora-coreos-*.3.*-qemu.x86_64.qcow2 | head -1) instead of downloading again (the .3. in the build number marks the stable stream, so the glob skips a next image): a second download truncates and rewrites the same file in place, under any VM that is using it.
Boot Fedora CoreOS on KVM with virt-install
QEMU passes the Ignition file to the guest through fw_cfg, so the file has to be readable by the confined QEMU process. Copying it into the images directory gives it the right SELinux label; moving it does not, as we found out.
sudo cp fcos01.ign /var/lib/libvirt/images/
Now create the VM in the same shell, so ${IMAGE} still holds the path. backing_store makes a 20 GB disk on top of the downloaded image instead of writing into it, and --import skips any installer. Older guides point the VM’s disk path straight at the downloaded image instead. That works once, but the first VM then writes into the image, so every later node starts from a disk that has already booted and run Ignition.
sudo virt-install --connect="qemu:///system" --name=fcos01 --vcpus=2 --memory=2048 \
--os-variant=fedora-coreos-stable --import --graphics=none --noautoconsole \
--disk="size=20,backing_store=${IMAGE}" \
--network bridge=virbr0 \
--qemu-commandline="-fw_cfg name=opt/com.coreos/config,file=/var/lib/libvirt/images/fcos01.ign"
For the second node we converted a copy of the file with the hostname changed, using sed "s/inline: fcos01/inline: fcos02/" fcos01.bu > fcos02.bu, and booted it on the next image with --os-variant=fedora-coreos-next. Download that image with STREAM="next" into its own variable, such as IMAGE_NEXT, so the stable path in IMAGE stays intact; it is signed with the Fedora 45 key. Give every node its own Ignition file. We reused one file for two VMs at first, and both came up with the hostname fcos01.
Find the address from the libvirt DHCP leases. On our host virsh domifaddr returned an empty table while the leases were plainly there, so we use the network’s lease list.
sudo virsh net-dhcp-leases default
Our host had been through several rebuilds, so the list also shows expired nodes. Pick the newest lease with your hostname, here 192.168.122.76:
Expiry Time MAC address Protocol IP address Hostname Client ID or DUID
------------------------------------------------------------------------------------------------------------
2026-10-17 01:10:36 52:54:00:92:40:2b ipv4 192.168.122.76/24 fcos01 01:52:54:00:92:40:2b
2026-10-17 00:41:13 52:54:00:a7:6b:c5 ipv4 192.168.122.181/24 fcos02 01:52:54:00:a7:6b:c5
2026-10-17 00:26:14 52:54:00:b9:51:3b ipv4 192.168.122.210/24 - 01:52:54:00:b9:51:3b
2026-10-17 00:41:08 52:54:00:ba:c9:8e ipv4 192.168.122.121/24 - 01:52:54:00:ba:c9:8e
2026-10-17 00:40:07 52:54:00:c1:ef:f5 ipv4 192.168.122.225/24 - 01:52:54:00:c1:ef:f5
Leases from destroyed VMs stay listed until they expire, and some keep a hostname: the fcos02 row here belongs to an earlier build. Go by the newest expiry time. If the hostname column is empty for your new node too, match it on the MAC address that virsh domiflist reports for the VM. Log in as core with the key from the Butane file, for example ssh [email protected]. FCOS has no password for core, so the key is the only way in unless you add more users in the config.
Verify the node
Check every part of the Butane file on the node yourself rather than trusting that it booted. These commands cover the deployment, the update window, zram and the container.
hostnamectl hostname
rpm-ostree status
sudo journalctl -u zincati -b --no-pager | grep -o "update strategy.*"
zramctl --noheadings
systemctl show web.service -p Restart
curl -s http://localhost:8080 | grep -o "<title>[^<]*"
On the stable node every line matched the config. FCOS now boots straight from a signed container image on quay.io, and Zincati reports itself as the update driver.
fcos01
State: idle
AutomaticUpdatesDriver: Zincati
DriverState: active; periodically polling for updates (last checked Sat 2026-10-17 00:10:50 UTC)
Deployments:
● ostree-image-signed:docker://quay.io/fedora/fedora-coreos:stable
Digest: sha256:e9d2be2eaf3934e164fbc59d1baee15473795f5e39290ec85f78a16e98fe51b5
Version: 44.20260928.3.1 (2026-10-13T17:19:28Z)
update strategy: periodic, total schedule length 120 minutes; next window at 22:30 on Sat (Africa/Nairobi), subject to time zone caveats.
/dev/zram0 lzo-rle 976.5M 4K 80B 12K [SWAP]
Restart=on-failure
<title>Welcome to nginx!
Four lines in that output tell you the Butane file did its job, and each one maps to a section of the config:
- Origin
ostree-image-signed:: updates are signature checked - Window in Africa/Nairobi: the
time_zoneline took effect /dev/zram0at 976.5M: half of the 2 GB of RAM, asmin(ram / 2, 4096)askedRestart=on-failure: the Quadlet unit carries the restart policy
The signed origin is worth a second look, because it is not the default on every Fedora image-based system; a Silverblue host switched to its container image runs unverified. If any of the four lines is missing, the matching block in your Butane file did not apply. Provisioning a fresh node from a corrected file is the clean fix, since Ignition never runs again on this one. To prove the restart policy works, kill the container and check again half a minute later:
sudo podman kill web >/dev/null; sleep 30
systemctl is-active web.service
systemctl show web.service -p NRestarts
Systemd brought the container back and counted the restart.
active
NRestarts=1
The next node gave the same results on the Fedora 45 base, with quay.io/fedora/fedora-coreos:next as its origin, so one config covers both streams. For more on writing container units like web.container, see our guide to Podman Quadlet with systemd on Fedora.
Run Fedora CoreOS on OpenStack
On OpenStack the Ignition file goes in as user data, and the same Butane config works with a different hostname. We drove a DevStack cloud from the same Fedora workstation, with the python3-openstackclient package installed and credentials in a clouds.yaml entry selected through OS_CLOUD. Before you start, you’ll need these in the project:
- A tenant network whose subnet hands out a DNS server
- A router with an external gateway, so instances can reach quay.io
- A flavor with at least 2 GB of RAM and a 10 GB disk
- A floating IP pool, or a provider network you can reach
The network names below, private and public, and the m1.small flavor are DevStack’s; use your cloud’s names. Download the OpenStack image with coreos-installer and derive the image name from the file it writes:
STREAM="stable"
FILE=$(coreos-installer download --decompress -s $STREAM -p openstack -f qcow2.xz 2>/dev/null)
IMAGE=$(basename "$FILE" .qcow2)
openstack image create --disk-format=qcow2 --min-disk=10 --min-ram=2048 --file="${FILE}" "${IMAGE}" -f value -c name -c status -c min_ram -c min_disk
The download took 58 seconds and the upload 19. Set --min-ram in megabytes: Fedora’s OpenStack guide shows --min-ram=2, which the client reads as 2 MB and which therefore blocks no flavor at all. With 2048 set, a test boot on the 512 MB m1.tiny flavor was refused with Flavor's memory is too small for requested image.
10
2048
fedora-coreos-44.20260928.3.1-openstack.x86_64
active
Convert a copy of the Butane file with this node’s hostname. The key it embeds is the one in the same directory, so this workstation can log in to the instance:
sed "s/inline: fcos01/inline: fcos-os01/" fcos01.bu > fcos-os01.bu
butane --pretty --strict --files-dir . fcos-os01.bu > fcos-os01.ign
Open SSH, the container port and ping in a security group. On a real cloud, add --remote-ip with your own network’s CIDR to the SSH rule rather than leaving it open to everyone:
openstack security group create fcos
openstack security group rule create --protocol tcp --dst-port 22 fcos
openstack security group rule create --protocol tcp --dst-port 8080 fcos
openstack security group rule create --protocol icmp fcos
Boot the instance with the Ignition file as user data, then give it a floating IP, because it only has an address on the private tenant network and nothing outside the cloud can route to it. On a public cloud the floating IP is reachable as is; DevStack’s floating range only routes through the DevStack host, so we added a static route to 172.24.4.0/24 on the workstation through the DevStack host. Then create the server and attach the address:
openstack server create --network private --flavor m1.small --image "${IMAGE}" --security-group fcos --user-data fcos-os01.ign --wait fcos-os01
FIP=$(openstack floating ip create public -f value -c floating_ip_address)
openstack server add floating ip fcos-os01 "$FIP"
echo "$FIP"
The instance went ACTIVE in 49 seconds. No config drive and no key pair are needed: the key comes from Ignition, and Ignition queries the config drive and the metadata service in parallel and takes whichever answers. On DevStack there was no config drive, so the metadata service at 169.254.169.254 served the user data:
Oct 17 03:07:39 localhost.localdomain ignition[595]: config drive ("/dev/disk/by-label/config-2") not found. Waiting...
Oct 17 03:07:39 localhost.localdomain ignition[595]: GET http://169.254.169.254/openstack/latest/user_data: attempt #1
Oct 17 03:07:39 localhost.localdomain ignition[595]: GET http://[fe80::a9fe:a9fe%25ens3]/openstack/latest/user_data: attempt #1
Oct 17 03:07:39 localhost.localdomain ignition[595]: GET error: Get "http://[fe80::a9fe:a9fe%25ens3]/openstack/latest/user_data": dial tcp [fe80::a9fe:a9fe%ens3]:80: connect: cannot assign requested address
Oct 17 03:07:39 localhost.localdomain ignition[595]: GET http://[fe80::a9fe:a9fe%25ens3]/openstack/latest/user_data: attempt #2
The IPv6 metadata errors are harmless on a cloud without IPv6 metadata; the IPv4 request is the one that matters. Over SSH to the floating IP, the node reported fcos-os01 from its /etc/hostname, the signed stable origin, the Nairobi update window and Restart=on-failure, and nginx answered on port 8080. Our first instance wasn’t that tidy. The tenant subnet had no DNS server and no working egress, so the first image pull failed with pinging container registry registry-1.docker.io and web.service ended up failed; that is where the restart policy in the Butane file comes from. If you need a test cloud of your own, our DevStack deployment guide is the quickest route.
Manage updates, packages and rollbacks
Zincati owns updates on FCOS, and rpm-ostree respects that. A manual upgrade is refused while Zincati is the registered driver:
sudo rpm-ostree upgrade
The error names the way around it, and that is the part to be careful with. A bypassed upgrade is one Zincati did not choose, so it skips the rollout pacing and update barriers Zincati follows, and you become responsible for when the node reboots into it:
error: Updates and deployments are driven by Zincati (zincati.service)
See Zincati's documentation at https://github.com/coreos/zincati
Use --bypass-driver to bypass Zincati and perform the operation anyways
Leave updates to Zincati and control them with the window in the Butane file; that’s what it’s for. A stream switch is the one routine job that needs it out of the way, and Fedora’s documentation stops the service rather than bypassing it. We moved the stable node to testing this way:
sudo systemctl stop zincati.service
STREAM="testing"
sudo rpm-ostree rebase "ostree-image-signed:docker://quay.io/fedora/fedora-coreos:${STREAM}"
The rebase took 5 minutes 39 seconds and staged the testing build; after a reboot, rpm-ostree status showed quay.io/fedora/fedora-coreos:testing on 44.20261012.2.1 with Zincati active again. Package layering works as on any rpm-ostree system and is not blocked by Zincati. sudo rpm-ostree install --apply-live htop took 56 seconds and htop ran without a reboot. Keep layers rare on FCOS, though: anything you can run as a container belongs in a Quadlet unit, where updates and rollbacks never have to resolve it.
Rollback is also allowed with Zincati active: sudo rpm-ostree rollback queued the previous deployment for the next boot straight away. Fedora’s manual rollback procedure stops zincati.service first, though, and that is the safer order. Zincati stages any newer release whenever one appears and reboots into it at the next window, so stop it while you investigate and start it again once you have a fix. The same rpm-ostree commands, explained in more detail, are in our guide to managing packages with rpm-ostree on Silverblue.
Fix common Fedora CoreOS errors
Each of these came up while building this guide, with the message the tools printed.
Cannot access backing file … (as uid:107, gid:107): Permission denied
We first followed the upstream libvirt example and kept the image under ~/.local/share/libvirt/images while creating the VM with sudo virt-install on qemu:///system. The system QEMU process runs as the qemu user, which cannot enter a Fedora home directory, so virt-install stopped with this:
ERROR Cannot access backing file '/home/josphat/.local/share/libvirt/images/fedora-coreos-44.20260928.3.1-qemu.x86_64.qcow2' of storage file '/var/lib/libvirt/images/fcos01.qcow2' (as uid:107, gid:107): Permission denied
Keep images in /var/lib/libvirt/images for the system connection. If you move existing images there, relabel them with sudo restorecon -Fv; the -F matters, because a plain restorecon left our moved files as svirt_home_t, reporting them as customised by an admin.
can’t load fcos02.ign: Failed to open file: Permission denied
This one is SELinux. We moved the Ignition file into the images directory with mv, so it kept its user_home_t label, and QEMU exited before the VM started:
ERROR internal error: process exited while connecting to monitor: 2026-10-16T22:03:07.336366Z qemu-system-x86_64: -fw_cfg name=opt/com.coreos/config,file=/var/lib/libvirt/images/fcos02.ign: can't load /var/lib/libvirt/images/fcos02.ign: Failed to open file “/var/lib/libvirt/images/fcos02.ign”: Permission denied
The audit log shows the cause in one line:
avc: denied { read } for pid=3452 comm="qemu-system-x86" name="fcos02.ign" dev="sda3" ino=38978 scontext=system_u:system_r:svirt_t:s0:c217,c270 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0
Run sudo restorecon -Fv /var/lib/libvirt/images/fcos02.ign, which relabeled it to virt_image_t, or use cp in the first place. Do not switch SELinux to permissive for this; the label is the whole problem.
Updates and deployments are driven by Zincati
You ran rpm-ostree upgrade or rebase while Zincati was managing updates. Stop zincati.service for a stream switch, as shown above, or let Zincati apply updates in its window. Reach for --bypass-driver only when you know why.
The node boots but SSH refuses the key
Check the Butane conversion first. A misspelled field produces a warning, not an error, unless you pass --strict, and the resulting Ignition config simply has no key in it. Run grep -c ssh-ed25519 fcos01.ign before you boot; ours printed 1 for the good file and 0 for the typo. Ignition will not run again on an existing node, so fix the file and provision a new one.
web.service failed after first boot
Read journalctl -u web.service -b. Ours said pinging container registry registry-1.docker.io, a network or DNS problem at first boot. Fix the network, then add a [Service] restart policy to the Quadlet file so the next failure heals itself, as in the config above. Without one, the unit waits for you to start it by hand.
Once this single node works, the same Butane file scales out: one copy per hostname for a small fleet, or a Butane base for the OKD cluster on Fedora CoreOS. On a desktop hypervisor, the same Ignition file also boots Fedora CoreOS in VirtualBox.