Fedora

Install Fedora CoreOS on Proxmox VE with Ignition

Fedora CoreOS now ships a dedicated Proxmox VE image, and the upstream steps get a VM booting in minutes. Two defaults still trip up almost every first attempt. If your Butane config sets /etc/hostname the way other FCOS guides do, the VM drops into an emergency shell, because Afterburn has already written the Proxmox VM name to that file. And the image has no QEMU guest agent, so Proxmox can’t show the VM’s IP address or shut it down through the agent until you add one.

Original content from computingforgeeks.com - post 147571

Below, you install Fedora CoreOS on Proxmox VE the way the official image expects. You create a storage for images and snippets, verify the download, and write a Butane config that layers the guest agent and runs a container. Then qm passes Ignition through the cloud-init drive, and you see which cloud-init settings FCOS honours. Every command below was run on Proxmox VE 9.2 with the stable Fedora CoreOS stream in October 2026, and the same steps were run first on a Proxmox VE 8.4 node.

Create a Proxmox storage for images and snippets

Proxmox only passes an Ignition file to a VM if it sits on a storage with the snippets content type, and the default local-lvm can’t hold snippets. A directory storage solves both jobs: it holds the downloaded disk image and the Ignition files. Run everything in this guide as root on the Proxmox host:

mkdir -p /var/coreos/images /var/coreos/snippets
pvesm add dir coreos --path /var/coreos --content images,snippets --nodes "$(hostname -s)"
pvesm status --storage coreos

The new storage shows as active. Check the last column before you download anything, because /var/coreos lives on the root filesystem of the host.

Name          Type     Status     Total (KiB)      Used (KiB) Available (KiB)        %
coreos         dir     active        21300404         4334244        15858808   20.35%

Decompressed, the image needs 1.9 GB, and the compressed download briefly sits next to it. That was no problem on the newer test node, but on our older Proxmox node the same storage landed on a root filesystem that was almost 90% full, with 7.2 GB free. If yours is tight, point --path at a bigger disk. The --nodes flag matters on a cluster: storage definitions in /etc/pve/storage.cfg apply to every node, and without it the other nodes create an empty /var/coreos of their own and show a coreos storage that looks usable but holds nothing. hostname -s gives the short node name Proxmox expects even when /etc/hostname holds the full domain name.

Download the Fedora CoreOS Proxmox image

The upstream guide downloads with coreos-installer, which isn’t packaged for the Debian base Proxmox runs on. You don’t need it. Fedora CoreOS publishes a stream metadata file with the URL and SHA-256 of every image, and the proxmoxve artifact is in it, so curl and the Python that ships with Proxmox do the same job. -f makes curl fail loudly on an HTTP error instead of saving an error page that Python then can’t parse:

STREAM=stable
curl -fsSL --retry 3 "https://builds.coreos.fedoraproject.org/streams/${STREAM}.json" -o /var/coreos/stream.json
cd /var/coreos/images
J=/var/coreos/stream.json
URL=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["architectures"]["x86_64"]["artifacts"]["proxmoxve"]["formats"]["qcow2.xz"]["disk"]["location"])' "$J")
SHA=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["architectures"]["x86_64"]["artifacts"]["proxmoxve"]["formats"]["qcow2.xz"]["disk"]["sha256"])' "$J")
curl -fsSLO --retry 3 "$URL"
echo "$SHA  $(basename "$URL")" | sha256sum -c

A mismatch means the file on disk is not the one Fedora published, so delete it and download again instead of importing it. You want this line:

fedora-coreos-44.20260928.3.1-proxmoxve.x86_64.qcow2.xz: OK

Then decompress it in place and keep the file name in a variable, because it carries the build number and changes with every FCOS release.

unxz "$(basename "$URL")"
QCOW=$(basename "$URL" .xz)
echo "$QCOW"
ls -lh /var/coreos/images

Only the qcow2 is left, at 1.9 GB:

fedora-coreos-44.20260928.3.1-proxmoxve.x86_64.qcow2
total 1.9G
-rw-r--r-- 1 root root 1.9G Oct 19 22:26 fedora-coreos-44.20260928.3.1-proxmoxve.x86_64.qcow2

If you do use coreos-installer on another machine, watch the paths in the upstream guide: its coreos-installer download ... -C /var/coreos line saves to /var/coreos, while the import command later reads from /var/coreos/images. Downloading straight into images/ as above keeps the two in step. Set STREAM=next for the stream that moves to a new Fedora release first.

Install Butane on the Proxmox host

Butane turns a readable YAML config into the Ignition JSON that FCOS reads on first boot. It ships as a single static binary on GitHub, so fetch the latest release instead of pinning one:

BUTANE_VER=$(curl -s https://api.github.com/repos/coreos/butane/releases/latest | grep '"tag_name"' | cut -d '"' -f 4)
echo "$BUTANE_VER"
curl -sSL -o /usr/local/bin/butane "https://github.com/coreos/butane/releases/download/${BUTANE_VER}/butane-x86_64-unknown-linux-gnu"
chmod +x /usr/local/bin/butane
butane --version

Both lines should name the same release. If the first one comes back empty, the API call failed and the download URL is broken, so stop and fix that before transpiling anything:

v0.29.0
Butane 0.29.0

If you’d rather keep the hypervisor clean, run Butane on your workstation instead (sudo dnf install butane on Fedora) and copy the finished .ign file into /var/coreos/snippets/ with scp.

Write the Butane config

The config below does three jobs, and it deliberately leaves the hostname out:

  • an SSH key on the core user
  • qemu-guest-agent layered on first boot
  • a small whoami web server as a Quadlet container, so you have something to test

Instead, the hostname comes from the VM name, for the reason in the error section below. Open the file.

vim /var/coreos/fcos01.bu

Paste the config as it is. The key line is a placeholder that the next step fills in, so you can’t ship a VM with somebody else’s key by accident:

variant: fcos
version: 1.7.0
passwd:
  users:
    - name: core
      ssh_authorized_keys:
        - REPLACE_WITH_YOUR_SSH_PUBLIC_KEY
systemd:
  units:
    - name: rpm-ostree-install-qemu-guest-agent.service
      enabled: true
      contents: |
        [Unit]
        Description=Layer qemu-guest-agent with rpm-ostree
        Wants=network-online.target
        After=network-online.target
        Before=zincati.service
        ConditionPathExists=!/var/lib/%N.stamp

        [Service]
        Type=oneshot
        RemainAfterExit=yes
        ExecStart=/usr/bin/rpm-ostree install -y --allow-inactive qemu-guest-agent
        ExecStart=/bin/touch /var/lib/%N.stamp
        ExecStart=/bin/systemctl --no-block reboot

        [Install]
        WantedBy=multi-user.target
storage:
  files:
    - path: /etc/containers/systemd/whoami.container
      mode: 0644
      contents:
        inline: |
          [Unit]
          Description=whoami test web server
          Wants=network-online.target
          After=network-online.target

          [Container]
          Image=docker.io/traefik/whoami:latest
          PublishPort=80:80

          [Service]
          Restart=always

          [Install]
          WantedBy=multi-user.target

The checks later in this guide run from the Proxmox host, so the simplest key to use is the host’s own root key. Create one if the host doesn’t have it yet, then write it into the placeholder:

[ -f /root/.ssh/id_ed25519.pub ] || ssh-keygen -t ed25519 -N "" -f /root/.ssh/id_ed25519 -q
sed -i "s|REPLACE_WITH_YOUR_SSH_PUBLIC_KEY|$(cat /root/.ssh/id_ed25519.pub)|" /var/coreos/fcos01.bu
grep -c 'ssh-ed25519 AAAA' /var/coreos/fcos01.bu

A count of 1 means the key is in place. To log in from your workstation instead, paste its public key over the placeholder by hand and run the ssh checks from that machine.

The guest agent unit follows the layering pattern in the Fedora CoreOS OS extensions docs. It runs once, before Zincati can start its own update, installs the package into a new deployment, writes a stamp file so it never runs again, and reboots into the new deployment. --allow-inactive keeps the unit from failing if a future FCOS release adds the agent to the base image. Expect one extra reboot on first boot. After that, each Zincati update has to fetch the layered package again from the Fedora repositories, so the VM needs to reach them to keep updating.

Transpile it into the snippets directory. --strict turns a misspelled key into an error instead of a warning, which matters here, because a config that silently loses its SSH key boots a VM you can’t log in to:

cd /var/coreos
butane --pretty --strict fcos01.bu > snippets/fcos01.ign

Error: “error creating /sysroot/etc/hostname: A file exists there already and overwrite is false”

Leave /etc/hostname out of the config, or the first boot ends here. We booted a VM with the usual storage.files entry for /etc/hostname on both Proxmox releases, and the serial console showed Afterburn writing the hostname first, then Ignition giving up and starting the emergency shell:

[   13.065590] afterburn[838]: Oct 19 22:44:33.389 INFO wrote hostname fcos04 to /sysroot/etc/hostname
[   13.780989] ignition[897]: Ignition failed: failed to create files: failed to create files: error creating /sysroot/etc/hostname: error creating file "/sysroot/etc/hostname": A file exists there already and overwrite is false
[  OK  ] Started emergency.service - Emergency Shell.[   14.423626] systemd[1]: Started emergency.service - Emergency Shell.

On the proxmoxve platform, afterburn-hostname.service reads the hostname from the cloud-init drive Proxmox builds, and that is the VM name you pass to qm create --name. So the easy fix is to name the VM what you want the host to be called. If the hostname has to differ from the VM name, keep the entry and add overwrite: true to it; the fcos02 example in the cloud-init section below does that.

A VM that stopped here can be recovered without rebuilding it. The first-boot flag is only cleared after a boot that completes, so we fixed the snippet with overwrite: true, transpiled it again, and ran qm stop and qm start on the same VM. Ignition ran again against the regenerated cloud-init drive, and the console confirmed it:

Ignition: ran on 2026/10/19 22:46:11 UTC (this boot)
Ignition: user-provided config was applied
fcos04 login:

That only works while the VM has never completed a boot. Once one has, Ignition won’t run again, and a config change means destroying the VM and creating it from the image.

Create the Fedora CoreOS VM

Set the values that change per VM once. STORAGE is where the VM disk goes, which can be any storage that holds VM images. Every later command reads these variables, so a second VM only needs new values here. If you opened a new shell since the download, set QCOW again from the file on disk:

QCOW=$(ls /var/coreos/images/ | grep 'proxmoxve\.x86_64\.qcow2$' | tail -1)
VM_ID=371
NAME=fcos01
IGN=fcos01.ign
STORAGE=local-lvm

Now create the VM, import the image as its disk, add the cloud-init drive, and point that drive at the Ignition snippet. --agent enabled=1 adds the virtio serial port the guest agent talks over, and --ciupgrade 0 stops Proxmox from asking cloud-init to upgrade packages, which means nothing on FCOS:

qm create ${VM_ID} --name ${NAME} --cores 2 --memory 2048 --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci --agent enabled=1
qm set ${VM_ID} --scsi0 "${STORAGE}:0,import-from=/var/coreos/images/${QCOW}"
qm resize ${VM_ID} scsi0 +10G
qm set ${VM_ID} --ide2 ${STORAGE}:cloudinit
qm set ${VM_ID} --boot order=scsi0 --serial0 socket --vga serial0
qm set ${VM_ID} --cicustom vendor=coreos:snippets/${IGN} --ciupgrade 0

The import prints a progress counter for the 10 GB disk and finishes with a line naming the new volume:

scsi0: successfully created disk 'local-lvm:vm-371-disk-0,size=10G'

The image disk is only 10 GB, which is why qm resize adds another 10; FCOS grows the root filesystem into the space on first boot, and we ended up with 19.4 GB of XFS. On a thin pool you may also see LVM warnings about overcommitment, which are about the pool, not this VM. --serial0 socket is what gives you a console you can read with qm terminal, and --vga serial0 shows the same serial console in the web UI, which is where an error like the hostname one above appears.

Boot the VM and find its address

Start the VM and wait for the guest agent rather than guessing at a DHCP lease. The loop below returns once the agent answers, which only happens after the layering reboot. So when it prints, the VM has finished both boots and is ready for SSH:

qm start ${VM_ID}; T0=$(date +%s)
until qm agent ${VM_ID} ping 2>/dev/null; do sleep 5; done; echo "guest agent answered $(( $(date +%s) - T0 )) s after qm start"

It took about two minutes on both Proxmox releases we tested. Most of that is the first boot, the package download and the reboot; an earlier VM without the layering unit finished booting in 24 seconds, but its agent never answered at all:

guest agent answered 124 s after qm start

You don’t need the DHCP server’s lease table to find the VM, because the agent reports its addresses:

qm guest cmd ${VM_ID} network-get-interfaces | grep -E '"ip-address" : "[0-9.]+"'

Straight after the wait, the call listed only the loopback and the LAN address from DHCP. Run a minute later, once the whoami container was up, it also showed 10.88.0.1, which belongs to the Podman bridge, not to your network:

            "ip-address" : "127.0.0.1",
            "ip-address" : "10.0.1.109",
            "ip-address" : "10.88.0.1",

The agent-reported addresses also appear on the VM’s Summary page in the Proxmox web UI (IPv4 blanked out in our capture):

Fedora CoreOS VM summary in Proxmox VE 9 showing addresses reported by the QEMU guest agent

Log in from the Proxmox host with the root key you put in the config, and check that everything in the config landed. An agent that answers only proves the first unit worked, because a mistake in the container unit doesn’t stop the VM from booting. Replace the address with yours:

IP=10.0.1.109
ssh -o StrictHostKeyChecking=accept-new core@${IP} "hostnamectl hostname; rpm-ostree status --booted | grep -E 'LayeredPackages|Version'; systemctl is-active qemu-guest-agent whoami; resolvectl dns ens18"

The hostname came from the VM name, the agent is a layered package on top of the signed stable image, and both units are running:

fcos01
                  Version: 44.20260928.3.1 (2026-10-13T17:19:28Z)
          LayeredPackages: qemu-guest-agent
active
active
Link 2 (ens18): 10.0.1.1

Finally, the container answers on port 80 from the host.

curl -s http://${IP}/ | head -2

whoami reports the container’s own hostname, a random ID, which is how you know the reply came from inside Podman:

Hostname: e9b2e68e0893
IP: 127.0.0.1

On the older node, free -m inside the VM showed 358 MiB used of 2 GB, and the second boot took 11.2 seconds by systemd-analyze. So 2 GB is plenty for a host that only runs a few containers. Updates arrive through Zincati as new signed images, and layered packages ride along with each update. The FCOS on KVM and OpenStack guide covers update windows and switching streams, and the rpm-ostree guide covers layering and rollback in more depth.

Set SSH keys, a static IP and DNS from Proxmox cloud-init

FCOS doesn’t run cloud-init the way the distros in the cloud-image template guide do, but Afterburn reads the cloud-init drive Proxmox builds, so some of the familiar Cloud-Init tab settings work and some don’t. That matters if you already hand out keys and addresses from the Cloud-Init tab, because you can keep doing it for the settings that work. We tested a second VM whose Butane config held nothing but a hostname override:

vim /var/coreos/fcos02.bu

There is no SSH key in it at all. overwrite: true is what lets this file replace the hostname Afterburn writes:

variant: fcos
version: 1.7.0
storage:
  files:
    - path: /etc/hostname
      mode: 0644
      overwrite: true
      contents:
        inline: web01

Transpile it first, from the same directory:

cd /var/coreos
butane --pretty --strict fcos02.bu > snippets/fcos02.ign

Then create VM 372 with the six commands from the create step, after changing three values:

  • VM_ID=372
  • NAME=fcos02
  • IGN=fcos02.ign

Before you start it, give Proxmox the SSH key, a static address and a resolver. Use a free address on your own LAN and your own gateway in place of ours, and ping the address first to make sure nothing answers. --sshkeys takes a path to a public key file rather than the key itself, and the host’s root key from earlier works:

qm set ${VM_ID} --sshkeys /root/.ssh/id_ed25519.pub --ipconfig0 ip=10.0.1.230/24,gw=10.0.1.1 --nameserver 1.1.1.1 --searchdomain example.lan
qm cloudinit dump ${VM_ID} network

qm cloudinit dump shows exactly what the guest will be handed:

version: 1
config:
    - type: physical
      name: eth0
      mac_address: 'bc:24:11:a5:73:59'
      subnets:
      - type: static
        address: '10.0.1.230'
        netmask: '255.255.255.0'
        gateway: '10.0.1.1'
    - type: nameserver
      address:
      - '1.1.1.1'
      search:
      - 'example.lan'

Start it, wait for SSH on the static address (it’s the only address the VM will have), then check the result:

qm start ${VM_ID}
until ssh -o ConnectTimeout=3 -o StrictHostKeyChecking=accept-new [email protected] true 2>/dev/null; do sleep 5; done
ssh [email protected] "hostnamectl hostname; resolvectl dns ens18; resolvectl domain ens18; getent hosts quay.io | head -1"

The login itself proves the key from --sshkeys reached the core user, through [email protected], which the image enables by default. The static address and the resolver were applied, and the Butane hostname beat the VM name:

web01
Link 2 (ens18): 1.1.1.1
Link 2 (ens18):
2600:1f18:d77:9000:958a:1993:59fc:8482 quay.io

We ran more variants to see where the cloud-init support stops:

  • the search domain was not applied, as the empty resolvectl domain line shows
  • --ipconfig0 ip=dhcp plus --nameserver 1.1.1.1 gave 1.1.1.1 10.0.1.1, the extra resolver next to the one from DHCP
  • with no --ipconfig0 at all, --nameserver was ignored and the VM used only the DHCP resolver
  • a static IP without --nameserver inherits the Proxmox host’s own resolvers

That last point bit us. Our first static test on the older node left --nameserver out, and Proxmox filled in the host’s /etc/resolv.conf, which on that node points at Tailscale’s 100.100.100.100 and its tailnet search domain. A guest outside the tailnet has no business using that resolver, so always pair --ipconfig0 with an explicit --nameserver, and set --searchdomain too if you don’t want the host’s domain copied into the cloud-init data.

Shut down, remove and reuse

With the agent in place, qm shutdown and the Shutdown button go through the guest agent, and it is quick:

VM_ID=371
time qm shutdown ${VM_ID}
qm status ${VM_ID}

The VM was down in a little over two seconds:

real	0m2.253s
user	0m0.526s
sys	0m0.074s
status: stopped

Without the agent, qm agent calls fail with QEMU guest agent is not running when --agent enabled=1 is set, or No QEMU guest agent configured when it isn’t. Anything in Proxmox that depends on the agent, such as the IP list or the filesystem freeze before a backup snapshot, quietly stops working. That is the strongest reason to keep the layering unit in every FCOS config you use on Proxmox.

Once a VM has booted, Ignition won’t run again, so a config change means a rebuild: edit the .bu file, transpile it again, destroy the VM and repeat the create commands. The rebuilt VM gets a new SSH host key, so clear the old one with ssh-keygen -R ${IP} before you log in again. The downloaded image in /var/coreos/images is reused every time, the same way a Flatcar VM on Proxmox is rebuilt from its Ignition file rather than patched by hand. Destroying a VM also removes its disks and cloud-init drive, and a running VM has to be stopped first:

qm destroy ${VM_ID} --purge
qm stop 372
qm destroy 372 --purge

Proxmox removes both volumes of each VM and its entries in other configs:

  Logical volume "vm-371-cloudinit" successfully removed.
  Logical volume "vm-371-disk-0" successfully removed.
purging VM 371 from related configurations..
  Logical volume "vm-372-cloudinit" successfully removed.
  Logical volume "vm-372-disk-0" successfully removed.
purging VM 372 from related configurations..

If you are done with Fedora CoreOS on this host altogether, drop the storage definition and its directory, and the Butane binary if you installed it here. Do this only after every VM that uses a coreos:snippets file is gone: Proxmox rebuilds the cloud-init drive on every start, and it needs the snippet to still exist when it does. pvesm remove only deletes the definition, so the rm is what frees the 1.9 GB image:

pvesm remove coreos
rm -rf /var/coreos /usr/local/bin/butane

Keep the storage if you plan more FCOS VMs. Every new one is the same six qm commands plus its own .ign snippet. The image only needs downloading again when you want a newer build to start from, because existing VMs update themselves through Zincati either way.

Keep reading

How to Upgrade Fedora 44 to Fedora 45 (GUI, CLI and Rollback) Fedora How to Upgrade Fedora 44 to Fedora 45 (GUI, CLI and Rollback) 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 Install KVM and Virt-Manager on Arch Linux Virtualization Install KVM and Virt-Manager on Arch Linux Switch Fedora From GRUB to systemd-boot With bootctl Fedora Switch Fedora From GRUB to systemd-boot With bootctl DNF5 Cheatsheet for Fedora 45 / 44: Tested Commands and Fixes Fedora DNF5 Cheatsheet for Fedora 45 / 44: Tested Commands and Fixes Create and Configure Bridge Networking For KVM in Linux Virtualization Create and Configure Bridge Networking For KVM in Linux

Leave a Comment

Press ESC to close