Tutorial 23 July 2026 7 min read Twenty VMs, two Windows servers, one Linux box By Old Forge Technologies 18 reads Contents For a while my little world ran on two Windows machines. Two Hyper-V servers - I call them Atlas and Hercules - between them carrying about twenty virtual machines. Each VM does exactly one job: calendaring here, uptime monitoring there, a helpdesk, an identity provider, a secrets store, a handful of small web apps. The tidy one-service-per-box discipline I like, sitting on top of a hypervisor I had stopped liking. Nothing was on fire. The servers worked. But two Windows hosts to patch and license, to keep Integration Services happy on, to babysit a GUI on, is two more moving parts than a one-person shop needs. I wanted one Linux box running KVM, all twenty VMs on it, and my weekends back. So I wrote a script and moved them, one at a time, in an afternoon. This is how it went, including the single line of YAML that stops everyone the first time. ## Why KVM and not something with a dashboard I did not want Proxmox or oVirt or anything with its own web UI and its own opinions. The target, a box I call **Zeus**, runs plain Ubuntu with **KVM** (the kernel's hypervisor), **QEMU** (the per-VM userspace process), and **libvirt** (the management layer `virsh` and Cockpit talk to). That is the whole stack. If I can drive it from a shell script over SSH, I can understand it at 2am, and I can put the script on a repo for the next person. The migration would be **cold** - stop each VM, copy its disk, boot it on the other side. Not live migration; I don't need zero downtime for a personal fleet, and cold is dramatically simpler and more reliable for a one-shot move. A VM is down only for the minutes its disk takes to convert. ## The shape of one migration Everything below happens per VM, driven by six arguments: name, which source host, MAC, memory, vCPUs, and expected IP. **Find the disk, stop the VM.** Windows has had OpenSSH and PowerShell for years, so the Linux box just asks the Hyper-V host directly: ``` ssh Administrator@atlas "(Get-VMHardDiskDrive -VMName 'MyServer').Path" ssh Administrator@atlas "Stop-VM -Name 'MyServer'" ``` **Convert the disk.** Hyper-V's disk format is VHDX; KVM's is qcow2. `qemu-img` reads the VHDX straight off a share mounted on Zeus and writes the qcow2 into the libvirt pool: ``` qemu-img convert -f vhdx -O qcow2 "$SRCFILE" myserver.qcow2 ``` **Define and boot it on KVM.** `virt-install --import` takes the finished qcow2, puts the disk and NIC on virtio, attaches it to the host bridge, and - the important part - **reuses the original MAC address**. Then `virsh autostart` so it comes back on reboot. Between convert and boot sits the one step that actually earns this post. ## The eth0 trap Here is the thing I would have paid money to read beforehand. Under Hyper-V, the guest's network card is driven by `hv_netvsc`, and it shows up inside the guest as **`eth0`**. Move that same disk to KVM, where the card is now virtio-net, and Linux names the interface **`enp1s0`** instead. If the guest configures its network by matching on the interface *name* - and Ubuntu's netplan does exactly that - it now matches an interface that no longer exists. The VM boots perfectly. The disk is fine, the services start, and it has **no network**. You find out when it never answers a ping and you're staring at `virsh console` watching a healthy machine with a dead NIC. The fix is to stop caring about the name. Before the VM ever boots, I mount the qcow2 offline with **guestfish** and write a netplan that matches the card by its **MAC address** - which I carried across from Hyper-V and which cannot change - and forces the name back to `eth0`: ```yaml network: version: 2 ethernets: primary: match: macaddress: "00:15:5d:..." set-name: eth0 dhcp4: true ``` Two details make it robust. First, guests disagree about where netplan lives - some have `50-cloud-init.yaml`, some `00-installer-config.yaml` - so the script *discovers* the existing files and moves them aside rather than assuming. Second, it disables cloud-init's networking, because otherwise cloud-init helpfully rewrites your careful override on the next boot. Match by MAC, pin the name, tell cloud-init to keep its hands off. Now the interface name genuinely cannot matter. ## Why the MAC is the whole game Preserving the MAC does more than fix the interface name. My DHCP server hands out addresses by **reservation** - this MAC always gets that IP. So the moment the migrated VM comes up with its original MAC, it gets its original address. DNS still resolves it. Firewall rules still match it. Every other service that talked to it by name or number carries on as if nothing happened, because from the network's point of view, nothing did. The machine's soul moved from Windows to Linux and kept its phone number. That is why the last step of each migration is simply to ping the expected IP until it answers. If it answers, the VM is home, at the same address, and I move to the next one. ## The result Twenty VMs now live on Zeus, all set to autostart, all bridged onto the LAN with their original MACs and IPs. Committed memory across the fleet is about 56 GiB against 124 GiB of RAM, so there is comfortable headroom - and, pleasingly, both Windows servers are now free to switch off. The whole thing is small enough to read: an inventory script that runs on the Hyper-V side, one migration script, and a driver loop. I put it on GitHub in case you are about to do the same thing and would like to skip the afternoon I spent on the netplan trap: **https://github.com/creativeheadz/hyperv-to-kvm** ## Then the backups had to move too A migration is only half a re-platforming; the nightly backup job had to come across as well. On Windows it was a scheduled `Export-VM` to the NAS. On KVM I replaced it with a script that takes a **live** snapshot of each running VM, backs up the base image while the VM keeps writing to a temporary overlay, then merges the overlay back with `blockcommit` - so nothing is ever stopped. Images are written zstd-compressed to fit more history in less space. One honest caveat, the same one I had under Hyper-V: these backups are **crash-consistent**, not application-consistent. Without a guest agent the host can't tell the guest to freeze its filesystem for a clean instant. The upgrade path is a one-liner - install `qemu-guest-agent` in each guest and the snapshots become filesystem-consistent - and it is the next thing on my list, starting with the database-backed VMs. ## Would I do it again Yes, and faster. The parts I braced for - the disk conversion, defining the domains - were boring, which is what you want. The part that bit me was the one I didn't know to expect, and now you do. If you take one thing from this: **when a Linux VM moves hypervisors and loses its network, it's almost always the interface name. Match on the MAC and the problem disappears.** --- *Notes for whoever is doing this next:* - *Cold migration is fine for a one-shot move. Don't reach for live migration you don't need.* - *Carry the MAC across on `virt-install` (`--network ...,mac=...`). It fixes networking AND keeps the DHCP-reserved IP.* - *The netplan fix is Ubuntu-specific. Other distros: the same idea via udev `.link`, NetworkManager, or `/etc/network/interfaces`.* - *Disable cloud-init networking, or it will overwrite your override on boot.* - *Gen-2 (UEFI) Hyper-V VMs map cleanly to the libvirt `q35` machine type.*