You just finished setting up a Fedora host with a few KVM guests
The hardware is aging, or you bought a newer machine, or you need to move a workload to a different network segment. You want to move the virtual machines without rebuilding them from scratch. The terminal is open, virsh is waiting, but the migration flags look intimidating. You need a clear path from source to destination without losing state or breaking SELinux.
What's actually happening
Virtualization on Fedora relies on KVM for hardware acceleration and QEMU for device emulation. Libvirt sits on top as the management layer. When you migrate a VM, you are not just copying files. You are moving a running process state, memory pages, and disk pointers across the network. Think of it like moving a running kitchen to a new house. Live migration keeps the chefs cooking while you swap the ingredients and equipment in the background. Offline migration shuts the kitchen down, packs everything in boxes, moves the boxes, and unpacks them at the new location. Both methods use the same underlying tools. The difference is whether the guest stays powered on during transit.
Libvirt tracks which memory pages the guest modifies during transfer. It sends the initial memory dump first, then repeatedly sends only the changed pages until the delta is small enough to pause the guest for a final sync. That pause is your actual downtime. It usually lasts less than a second on a healthy network. Offline migration skips the memory tracking entirely. You stop the guest, move the static files, and start it again. The tradeoff is predictable downtime versus network and storage complexity.
Preparing the hosts
Both machines must run Fedora with the libvirt and qemu-kvm packages installed. The libvirtd daemon must be active on both sides. SSH key authentication must work from the source to the destination host. Password prompts will break the migration stream because libvirt cannot prompt for credentials during an automated transfer. Open the required ports on the destination firewall before you start. Libvirt uses a dedicated management port and a dynamic range for memory transfer.
Here is how to open the correct ports on the destination host.
sudo firewall-cmd --permanent --add-port=16509/tcp # libvirt management protocol for migration control and negotiation
sudo firewall-cmd --permanent --add-port=49152-49215/tcp # dynamic range for live memory page transfer streams
sudo firewall-cmd --reload # applies the new rules to the active firewall session immediately
Run firewall-cmd --reload after every rule change. Otherwise the runtime config and the persistent config diverge, and your next reboot will drop the migration traffic.
Live migration
Live migration copies the VM's memory to the destination while it continues running. Both hosts must have access to the same disk image via shared NFS, iSCSI, or Ceph. The disk stays on the network volume. Only memory and CPU state move across the wire.
Here is how to trigger a standard live migration over an encrypted SSH tunnel.
virsh migrate --live <vm-name> qemu+ssh://<destination-host>/system # transfers memory and CPU state while guest stays running
# the qemu+ssh:// URI tells libvirt to tunnel migration traffic over SSH
# this avoids exposing raw libvirt ports to untrusted network segments
If shared storage is not available, you must transfer the disk image over the network as well. Libvirt calls this block migration. It streams the disk contents alongside the memory pages. The destination host will write the disk to its local storage while the guest continues to run on the source.
Here is how to run a live migration that copies the disk image simultaneously.
virsh migrate --live --copy-storage-all <vm-name> qemu+ssh://<destination-host>/system # streams both memory and disk data to the target
# --copy-storage-all tells libvirt to replicate the virtual disk during the migration window
# the destination host must have enough free space to receive the full disk image
Monitor the transfer progress from the source terminal. Libvirt tracks the job in real time and reports bandwidth usage, data transferred, and estimated completion time.
virsh domjobinfo <vm-name> # displays active migration statistics including bandwidth and remaining time
# the output updates dynamically while the job runs
# watch the 'Remaining' field to gauge when the final sync will occur
Check the job status before interrupting. Killing a migration mid-stream leaves the guest in an undefined state on both hosts.
Offline migration
Offline migration is simpler and does not require shared storage. You shut the VM down, export its definition, copy the disk, and reimport on the destination. This approach works across different CPU architectures, different libvirt versions, and completely isolated networks.
Export the VM definition first. The XML file contains every hardware mapping, network attachment, and boot parameter. It is safer than manually recreating the configuration.
virsh dumpxml <vm-name> > /tmp/<vm-name>.xml # exports the complete domain configuration including hardware mappings
# the XML preserves disk paths, NIC MAC addresses, and CPU feature flags
# save it to a temporary directory so it does not clutter your home folder
Copy the disk image and the XML file to the destination host. Use scp to keep the transfer encrypted and to leverage your existing SSH keys.
scp /var/lib/libvirt/images/<vm-name>.qcow2 user@destination-host:/var/lib/libvirt/images/ # transfers the virtual disk over SSH
scp /tmp/<vm-name>.xml user@destination-host:/tmp/ # moves the configuration file to the target host
# verify the file sizes match before proceeding to avoid silent corruption
SSH into the destination host and register the VM. Libvirt will validate the XML, check for conflicting names, and prepare the domain for boot.
virsh define /tmp/<vm-name>.xml # registers the VM definition with the local libvirt daemon
virsh start <vm-name> # boots the guest using the newly imported configuration
# define does not start the VM automatically. it only creates the persistent configuration
Run virsh list --all to confirm the domain appears in the inactive or active list. Trust the package manager and the daemon. Manual file edits drift, snapshots stay.
Handling SELinux and storage paths
Fedora enforces strict file contexts for virtualization. Libvirt expects disk images in /var/lib/libvirt/images/. If you place images in a custom directory, SELinux blocks access and the guest fails to boot. The denial shows up in the journal with a clear AVC message.
Here is how to fix the context for a custom storage path.
sudo semanage fcontext -a -t virt_image_t "/custom/path(/.*)?" # adds a persistent rule to the SELinux policy store
sudo restorecon -Rv /custom/path # applies the new label to existing files and verifies the change
# semanage writes to the policy database so the label survives filesystem relabeling
# chcon only touches the file and will be overwritten by a future restorecon run
SELinux denials show up in journalctl -t setroubleshoot with a one-line summary. Read those before disabling SELinux. The policy usually tells you exactly which label is missing.
Disk formats and CPU compatibility
You may need to change the disk format during migration. Raw images are simple but waste space. QCOW2 supports snapshots, compression, and thin provisioning. Convert before copying to avoid transferring unnecessary zeros.
Here is how to convert a raw disk to QCOW2.
qemu-img convert -f raw -O qcow2 /path/to/disk.raw /path/to/disk.qcow2 # transforms the disk format while preserving all data
# -f raw specifies the source format. -O qcow2 specifies the target format
# the conversion reads the entire source file and writes a new optimized image
CPU compatibility is the most common cause of migration failures. Libvirt defaults to host-passthrough, which exposes the exact physical CPU model to the guest. If the destination host has a different generation or missing instruction sets, the guest will crash or refuse to start. Change the CPU mode to host-model in the XML before defining the VM on the new host.
Here is the correct CPU configuration block for cross-host migration.
<cpu mode='host-model' check='partial'>
<!-- host-model emulates a generic CPU that both source and destination can support -->
<!-- check='partial' allows migration even if the destination lacks a few optional features -->
<!-- this prevents boot failures when moving between different Intel or AMD generations -->
</cpu>
Edit the XML with virsh edit <vm-name> rather than a text editor. Libvirt validates the syntax and rejects malformed tags before they break the daemon.
Verify it worked
Run virsh dominfo <vm-name> on the destination to confirm memory, vCPU count, and state match the source. Check the network interface with virsh domiflist <vm-name> to verify MAC addresses and bridge attachments. Look at the guest console with virsh console <vm-name> to ensure the OS booted cleanly.
Here is how to check the daemon logs if something looks wrong.
journalctl -xeu libvirtd # shows recent libvirt daemon logs with explanatory context
# the -x flag adds explanation text for common systemd failure codes
# the -e flag jumps to the end of the journal so you see the latest events
Reboot before you debug. Half the time the symptom is gone after a clean network reset and a fresh libvirt restart.
Common pitfalls and error patterns
SSH key authentication fails. Libvirt will hang or abort with error: internal error: unable to execute QEMU command 'migrate': Connection reset by peer. Verify ssh -i ~/.ssh/id_ed25519 user@destination-host works without a password prompt.
Firewall blocks the dynamic port range. You will see error: internal error: async job failed: migration stream error: Connection timed out. Confirm the 49152-49215 range is open on the destination. SELinux blocks custom paths. The guest logs show qemu-system-x86_64: -drive file=/custom/path/disk.qcow2,if=none,id=drive-virtio-disk0: Could not open '/custom/path/disk.qcow2': Permission denied. Apply the virt_image_t context and run restorecon.
CPU mismatch crashes the guest. The journal prints qemu-system-x86_64: CPU model 'host-passthrough' is not supported on this host. Switch to host-model with check='partial' in the XML.
Disk format mismatch breaks boot. The guest hangs at the bootloader with error: you need to load the kernel first. Convert the disk with qemu-img or update the XML to match the actual format.
Run journalctl -xe first. Read the actual error before guessing.
When to use which migration method
Use live migration with shared storage when you need zero downtime and both hosts can mount the same NFS or iSCSI volume. Use live migration with --copy-storage-all when you lack shared storage but cannot afford a maintenance window. Use offline migration when you are moving across different CPU generations, different libvirt versions, or completely isolated networks. Use qemu-img convert when you want to reduce disk footprint or enable snapshot support on the destination. Use host-model CPU configuration when the source and destination hardware differ. Stick to host-passthrough only when both hosts run identical CPU models.