Run a Hetzner Cloud server on your own KVM host: raw image to libvirt and qcow2
Boot a Hetzner Cloud disk image on KVM with libvirt: qcow2 or raw, UEFI or BIOS, virt-install, then the network and cloud-init fixes for the first boot.
7 minute read by Dominik Geimer
A disk image of a Hetzner Cloud server is an ordinary raw disk: a partition table, usually an EFI system partition, and a root filesystem. KVM runs that without drama. What trips people up is everything around the disk: which firmware to give the virtual machine, which disk bus to use, and a network configuration written for a Hetzner host that no longer exists.
This guide starts where you already have the image on your KVM host and ends with a machine that boots, has a network and does not phone home to Hetzner's metadata service. If you do not have the image yet, Can you download a Hetzner Cloud snapshot? explains the options, and none of them is a Hetzner button: the API has no image download.
One caveat up front. The commands below follow the libvirt, QEMU and Hetzner documentation, and the same image has been boot-checked on Hyper-V. We have not run it on a KVM host ourselves, so treat the first boot as a rehearsal and keep the original server running until you have seen it come up.
What you need on the host
Any Linux machine with hardware virtualisation, libvirt and QEMU. On Debian or Ubuntu:
sudo apt install qemu-system-x86 libvirt-daemon-system virtinst ovmf zstd qemu-utils
sudo systemctl enable --now libvirtd
On Fedora, Rocky or Alma the equivalent is sudo dnf install qemu-kvm libvirt virt-install edk2-ovmf zstd qemu-img. Check that the CPU exposes virtualisation with egrep -c '(vmx|svm)' /proc/cpuinfo (anything above zero is fine) and that /dev/kvm exists.
Architecture matters. A Hetzner x86 server (CX, CPX, CCX) image runs on an x86-64 host. The Arm types (CAX) are Arm64 images and need an Arm host; running them under emulation on x86 works but is slow enough to be useful only for looking inside.
Raw or qcow2
Decompress first:
zstd -d server.img.zst -o server.raw
You can use the raw file as it is, or convert it to qcow2. The choice is practical, not about correctness:
| raw | qcow2 | |
|---|---|---|
| Snapshots of the VM | no (use LVM, ZFS or btrfs underneath) | yes, built in |
| Thin allocation | only if the filesystem supports holes | yes |
| Speed | slightly faster, no metadata layer | slightly slower |
| Needs conversion | no | yes, needs free space and time |
| Compression, backing files | no | yes |
If the host runs on ZFS, btrfs or LVM-thin, raw is the simpler choice. On a plain ext4 host where you want virsh snapshot-create-as, use qcow2:
qemu-img convert -p -f raw -O qcow2 server.raw web-01.qcow2
qemu-img check web-01.qcow2
Add -c to compress the output, at the cost of a slower conversion; the qemu-img manual documents the flag. Zeroed blocks are not stored, so the qcow2 is about as large as the data on the disk, not the disk itself. The same conversion with other targets (VMDK, VHDX, VDI) is covered in the qemu-img conversion guide.
Copy the result into libvirt's image directory. For a raw file, keep the holes so you do not write 40 GB of zeroes:
sudo cp --sparse=always server.raw /var/lib/libvirt/images/web-01.raw
# or, for qcow2
sudo mv web-01.qcow2 /var/lib/libvirt/images/
Decide the firmware before you create the machine
A UEFI image needs OVMF and a legacy-BIOS image needs SeaBIOS (libvirt's default). The wrong choice fails before any operating system appears, with a blank screen or a UEFI shell, and it looks like a broken image.
Hetzner does not use one firmware everywhere. Its changelog announced in August 2023 that new server types use UEFI by default, and a stock x86 cx23 that we examined had an EFI system partition (Hetzner Cloud changelog). Older servers can still be legacy BIOS. Do not guess; read the partition table:
sgdisk -p server.raw # or: fdisk -l server.raw
A partition of type EFI System (code EF00) means UEFI. Only a BIOS boot partition (EF02), or none of either, means legacy BIOS. Arm images are always UEFI.
Create the machine with virt-install
For a UEFI image:
sudo virt-install \
--name web-01 --memory 4096 --vcpus 2 --cpu host-passthrough \
--import --disk path=/var/lib/libvirt/images/web-01.raw,format=raw,bus=virtio \
--network network=default,model=virtio \
--osinfo detect=on,require=off \
--boot uefi \
--graphics vnc --noautoconsole
For qcow2, change the disk to format=qcow2 and the file name. For a BIOS image, drop --boot uefi. What each option does:
--importskips the installer and boots the existing disk. See the virt-install manual.--osinfo detect=on,require=offlets virt-install guess the guest OS and carry on when it cannot. Recent versions refuse to run without an--osinfovalue.--boot uefiselects your distribution's OVMF build. It also enables Secure Boot on some distributions. If the firmware stops with "Access Denied" because the image has a self-built kernel or an unsigned loader, use--boot uefi,firmware.feature0.name=secure-boot,firmware.feature0.enabled=no.bus=virtiois faster than emulated SATA and works because Hetzner's own servers are virtio guests, so the images carry the drivers. If an image fails to find its root disk, switch tobus=satafor the first boot.--graphics vncgives you a console in virt-manager orvirt-viewer web-01. A serial-only console (--graphics none) stays blank on images that log to tty1.
If virt-install complains that the default network is not active, run sudo virsh net-start default and, once, sudo virsh net-autostart default. The NAT network gives guests an address from 192.168.122.0/24 over DHCP.
In virt-manager the same thing is: New VM, Import existing disk image, browse to the file, tick "Customize configuration before install", and set Firmware to UEFI or BIOS under Overview.
The first boot: it starts, but nothing can reach it
This is the moment people decide the image is broken. It is not. Hetzner servers carry a static configuration: a fixed public address and the gateway 172.31.1.1 (Hetzner static configuration), tied to one network card. None of that exists on your bridge. Open the console and log in. If you only have SSH keys, use virsh console web-01 if serial is configured, or reset a password by booting a rescue ISO, not by guessing.
1. Find the new interface name
ip -br link
On libvirt the card is usually enp1s0 on the q35 machine type, but the name depends on the machine type, so read it off. Hetzner's configuration talks about eth0, and on Ubuntu images it is matched by the MAC address of the original card, which a new virtual NIC does not have.
2. Give it an address
Ubuntu, netplan. Match on the name pattern, not the MAC:
# replace /etc/netplan/50-cloud-init.yaml
network:
version: 2
ethernets:
lan:
match: { name: "e*" }
dhcp4: true
sudo netplan apply
Debian, /etc/network/interfaces.d/50-cloud-init:
auto enp1s0
iface enp1s0 inet dhcp
sudo systemctl restart networking
Rocky, Alma and Fedora use NetworkManager. nmtui with IPv4 set to Automatic on the new device does the same; nmcli connection add type ethernet ifname enp1s0 con-name lan ipv4.method auto is the scripted version.
If the host is meant to be reachable from the outside at a fixed address, replace DHCP with your own static address and gateway, or attach the guest to a bridge (--network bridge=br0) instead of the NAT network. libvirt documents both in its networking guide.
3. Switch off what only made sense at Hetzner
sudo touch /etc/cloud/cloud-init.disabled
sudo apt remove hc-utils # Debian images; dnf remove on Rocky/Alma; Ubuntu does not have it
cloud-init either disables itself on a host with no metadata service or waits a minute or two for one, and on some images it rewrites the network configuration you just fixed. The empty file is its supported off-switch (cloud-init docs). hc-utils is Hetzner's helper for private networks and would try to configure interfaces on its own (Hetzner network docs).
Keep qemu-guest-agent if the image has it. On libvirt, add the channel so the host can talk to it:
sudo virsh attach-device web-01 --live --config /dev/stdin <<'EOF'
<channel type='unix'><target type='virtio' name='org.qemu.guest_agent.0'/></channel>
EOF
4. If it stops at an emergency prompt
grep HC_Volume /etc/fstab
That is an attached Hetzner volume. Volumes are never part of a snapshot (Hetzner FAQ), so no image made from one contains them. A line without nofail drops the boot into the emergency shell. Comment it out or add nofail, reboot, and restore that data from its own backup. Hetzner snapshots do not include volumes explains how to find the servers where this matters.
5. If the copy runs next to the original
sudo rm -f /etc/machine-id /var/lib/dbus/machine-id && sudo systemd-machine-id-setup
sudo rm -f /etc/ssh/ssh_host_* && sudo ssh-keygen -A && sudo systemctl restart ssh
A replacement keeps its identity so known_hosts still matches. A second, parallel copy should not share a machine-id or SSH host keys with the first.
When it still does not boot
| Symptom | Likely cause | Fix |
|---|---|---|
| Blank screen, then UEFI shell | BIOS image on OVMF, or empty NVRAM | Check sgdisk -p; for UEFI pick EFI\debian or EFI\ubuntu from the boot menu once |
| "Access Denied" from firmware | Secure Boot with an unsigned loader | Boot with Secure Boot off |
No bootable device on BIOS |
UEFI image on SeaBIOS | Re-create with --boot uefi |
| Kernel panic, cannot find root | Disk bus the initramfs lacks a driver for | Boot once with bus=sata, then rebuild the initramfs |
| Boots, hangs for 1 to 2 minutes at cloud-init | Waiting for the metadata service | Step 3 |
| Boots, no network | Wrong interface name or gateway | Steps 1 and 2 |
Check your work without involving libvirt at all by booting the file directly in QEMU: qemu-system-x86_64 -enable-kvm -m 4096 -drive file=server.raw,format=raw,if=virtio -bios /usr/share/ovmf/OVMF.fd (the OVMF path differs per distribution; leave -bios out for a BIOS image). If it boots there, the image is fine and the problem is in the machine definition.
Make the copy worth having
A running copy on your own KVM host is a recovery target, but only if it stays current and stays out of the same failure domain as the original. Two habits help. Re-export on a schedule that matches how much data you can afford to lose, and store the image somewhere that is not the KVM host itself and not your Hetzner account. The disaster recovery plan turns that into numbers: an RPO, an RTO and a one-page runbook. If the aim is to leave Hetzner rather than only to hold a copy, the Proxmox variant of this guide is Migrate a Hetzner Cloud VM to Proxmox without downtime, and rescue-mode dd is covered in restoring a raw image to a new Hetzner Cloud server.
Remember what an image contains. It is a crash-consistent copy of the disk, so databases come back as they would after a power cut. Attached volumes are never inside it, whatever tool produced it.
Getting the image
You can build it yourself with the manual route in the snapshot article, which involves a short outage or some careful handling of a cloned server. hetshield is the other option: it works agentlessly with only a Hetzner API token, needs no downtime, only snapshots the source and never reboots it, never boots your system on the clone, and gives you a compressed raw image to download. Volumes are excluded. It costs $29 flat plus VAT per export and is refunded if the export does not finish. In our measurement a 40 GB disk was read in 1:52, and a complete export took about nine minutes end to end. What it does in your project is listed in the docs, including the libvirt section that matches this guide.
Whichever way the image reaches you, the sequence on the KVM side stays the same: decompress, read the partition table, choose the firmware, import, then fix the network before you decide anything is broken.