Infrastructure simplifying engineer
Date: 2026-10-06
Moving from VMware to Proxmox looks like a hypervisor migration without touching IaC parts above. It is a false assumption. There are a lot of pitfalls on this way:
In our VMware environment, Terraform managed hundreds of VMs. We had quite mature IaC stack and processes(aka IDLC):
That workflow had taken time to develop(Ansible: CoreOS to CentOS, 18 months long journey), and we wanted to carry its useful parts forward as we started working with Proxmox. The familiar tools did not make the new platform behave like the old one.
Due to cost efficiency we started to think about alternatives. We had physical servers, storage systems, etc and wanted to re-use it. Not many options were available in our case
| Platform | Proxmox VE (KVM/QEMU) | XCP-ng + Xen Orchestra | Microsoft Hyper-V | Vanilla KVM (libvirt only) | VMware |
|---|---|---|---|---|---|
| Cost Licensing | free | free | ??? | free | ??? |
| Cost Support | ~1k–2k€/year | ~1k–2k€/year | ??? | N/A | ??? |
| Live Migration | yes | yes | yes | yes | yes |
| Windows VM Support | Good with virtio | Good (Xen PVHVM) | Best Windows support (native drivers) | Good with virtio | yes |
| FC SAN Support | Yes (FC via multipath) | Yes (FC via multipath) | Possible but FC is more complex | Yes (FC via multipath) | yes |
| Packer Templates | yes | yes | yes | yes | yes |
| Terraform Integration | yes | yes | yes | yes | yes |
| Ease of Management | good | good | medium, SCVMM is required | low | good |
| Community | huge | Medium | Medium | small | huge |
| Customizability | Medium | Medium | Medium | High | Medium |
| Migration Complexity | Medium: manual VM rebuild or scripted conversion | Medium: manual VM rebuild or scripted conversion | High: image formats differ | High: DIY everything | N/A |
Looks like the most realistic choice for small/mid-size clusters. out of the box:
Very strong alternative. Slightly more enterprise-oriented than Proxmox. Extremely stable, widely used in hosting providers:
Only makes sense if you want to build your own cloud. You don’t.
Choosing a hypervisor was only part of the story. The next question was: where do we put the VM disks so that VMs can move between hosts? Or maybe we use local disks?
The requirements were quite simple:
We already had a storage array, a storage fabric, and physical servers. The array could provide iSCSI and NFS. We wanted to reuse this hardware, not build a new storage platform from scratch.
| Option | Local FS | NFS | Shared LVM over FC | Clustered filesystem | Ceph RBD |
|---|---|---|---|---|---|
| Proxmox integration | Native | Native | Native | clustering configured separately | Native |
| How VM disks are stored | Files on each host | Files on a shared export | Logical volumes on shared LUNs | Files on GFS2 or OCFS2 | Distributed block images |
| Support VM disks | Yes | Yes | Yes | Yes | Yes |
| Support ISO images | Yes | Yes | No | Yes | No |
| Support snippets | Yes | Yes | No | Yes | No |
| VM disk formats | raw, qcow2, vmdk | raw, qcow2, vmdk | raw | raw, qcow2, vmdk | raw |
| Snapshots | With qcow2 | With qcow2 | No | With qcow2 | Yes |
| Linked clones | With qcow2 | With qcow2 | No | With qcow2 | Yes |
| Live migration | No | Yes | Yes | Yes | Yes |
| Reuses existing storage array | No | Yes | Yes | No | No |
| Good | Simple setup | Simple setup | flexible | ||
| Bad | host-loss recovery needs another copy | Depends on the network | hard to maintain | very hard to maintain and fragile | extremely hard to maintain |
In our shortlist we had NFS & FC. NFS looked simpler, but performance so bad that I’m too shy to share the numbers. However, we decided to use combination of NFS & FC(a bit later what for).
There is no silver bullet in choosing storage by protocol name. The idea was quite simple
The test sequence with params for fio 64G file during 3600 seconds for each profile. It’s not the best performance measuring approach but allows to get some ideas and extrapolate the results.
randread-4k: random reads with 4 KiB blocks.randwrite-4k: random writes with 4 KiB blocks.read-1m: sequential reads with 1 MiB blocks.write-1m: sequential writes with 1 MiB blocks.VMware
| Metric | Description | randread-4k | randwrite-4k | read-1m | write-1m |
|---|---|---|---|---|---|
| aggregate_iops | Total IOPS from all VMs | 9950 | 1862 | 378 | 227 |
| average_vm_iops | Average IOPS per VM | 995 | 186 | 38 | 23 |
| min_vm_iops | Lowest IOPS from one VM | 740 | 140 | 22 | 17 |
| max_vm_iops | Highest IOPS from one VM | 2164 | 376 | 44 | 28 |
| aggregate_mib_per_second | Total speed from all VMs in MiB/s | 39 | 7 | 378 | 227 |
| average_vm_mib_per_second | Average speed per VM in MiB/s | 4 | 1 | 38 | 23 |
| weighted_mean_latency_ms | Average latency weighted by I/O count, in milliseconds | 64 | 344 | 423 | 703 |
| average_vm_p50_latency_ms | Average p50 latency across VMs, in milliseconds | 61 | 314 | 384 | 595 |
| average_vm_p70_latency_ms | Average p70 latency across VMs, in milliseconds | 78 | 481 | 543 | 925 |
| average_vm_p80_latency_ms | Average p80 latency across VMs, in milliseconds | 93 | 546 | 655 | 1119 |
| average_vm_p90_latency_ms | Average p90 latency across VMs, in milliseconds | 121 | 620 | 829 | 1434 |
| average_vm_p95_latency_ms | Average p95 latency across VMs, in milliseconds | 151 | 703 | 1000 | 1855 |
| average_vm_p99_latency_ms | Average p99 latency across VMs, in milliseconds | 226 | 2227 | 1424 | 2849 |
Proxmox
| Metric | Description | randread-4k | randwrite-4k | read-1m | write-1m |
|---|---|---|---|---|---|
| aggregate_iops | Total IOPS from all VMs | 2287 | 7028 | 73 | 116 |
| average_vm_iops | Average IOPS per VM | 229 | 703 | 7 | 12 |
| min_vm_iops | Lowest IOPS from one VM | 142 | 372 | 5 | 8 |
| max_vm_iops | Highest IOPS from one VM | 292 | 1037 | 11 | 17 |
| aggregate_mib_per_second | Total speed from all VMs in MiB/s | 9 | 27 | 73 | 117 |
| average_vm_mib_per_second | Average speed per VM in MiB/s | 1 | 3 | 7 | 12 |
| weighted_mean_latency_ms | Average latency weighted by I/O count, in milliseconds | 280 | 91 | 2203 | 1373 |
| average_vm_p50_latency_ms | Average p50 latency across VMs, in milliseconds | 219 | 87 | 2250 | 1362 |
| average_vm_p70_latency_ms | Average p70 latency across VMs, in milliseconds | 421 | 115 | 2679 | 1689 |
| average_vm_p80_latency_ms | Average p80 latency across VMs, in milliseconds | 484 | 139 | 2965 | 1934 |
| average_vm_p90_latency_ms | Average p90 latency across VMs, in milliseconds | 603 | 183 | 3466 | 2337 |
| average_vm_p95_latency_ms | Average p95 latency across VMs, in milliseconds | 730 | 237 | 3956 | 2750 |
| average_vm_p99_latency_ms | Average p99 latency across VMs, in milliseconds | 979 | 479 | 5273 | 4033 |
the outcome is not that straight forward. There is no clear overall winner.

Almost everything was stored in git. There were some linked processes:
The idea was to implement that flow with Proxmox as a foundation.

To run some load it was required to build golden image. It was quite simple.
Steps to reproduce:
couldn't read boot diskResult: created VM is not bootable How to avoid: specify efi, disk/vm size/type Example:
resource "proxmox_vm_qemu" "virtual_machine" {
name = "testvm"
full_clone = true
bios = "ovmf"
machine = "q35"
scsihw = "virtio-scsi-single"
efidisk {
storage = "lv-vm01"
format = "raw"
efitype = "4m"
pre_enrolled_keys = false
}
Steps to reproduce:
Result: created VM is not reachable by ssh How to avoid: specify VLAN Example:
network {
id = 0
model = "virtio"
bridge = "vmbr0"
tag = 111
}
We use DNS + DHCP integration. VMs send hostnames as a part of DHCP request and it makes them reachable.
Steps to reproduce:
Result: created VM is not reachable How to avoid: use cloud-init for VM reboot Example:
#cloud-config
power_state:
delay: "+1"
mode: reboot
message: "Rebooting after initial cloud-init configuration"
timeout: 30
condition: [mkdir, /var/lib/cloud-init-reboot-after-first-boot]
We use DNS + DHCP integration. VMs send hostnames as a part of DHCP request and it makes them reachable.
Steps to reproduce:
cicustomResult: whole cloud-init config is overwritten and hostname is not set by Proxmox.
How to avoid: use correct cicustom
Example:
resource "proxmox_vm_qemu" "xxx" {
name = "xxx"
cicustom = "vendor=storage:snippets/reboot-after-first-boot.yaml"
...
terraform applySteps to reproduce:
terraform applyterraform applyResult: infrastructure is not in consistent state How to avoid: use remote state
Approximately at this point we were able to follow to create & manage VMs like we did. We started polishing details. We were moving from MVP to production.
Steps to reproduce:
Result: created VM is not reachable How to avoid: create shared storage with snippet support. In our case we already had NFS storage so some minor changes in Proxmox were missing. Example:
# cat /etc/pve/storage.cfg
nfs: vm-images
export /pve
path /mnt/pve/vm-images
server 192.168.xxx.yyy
content rootdir,snippets,images
prune-backups keep-all=1
Steps to reproduce:
Result: created VM is not reachable How to avoid: use HA cluster feature Example:
resource "proxmox_vm_qemu" "virtual_machine" {
name = "testvm"
target_nodes = ["xxx", "yyy", "zzz"]
hastate = "started"
Steps to reproduce:
│ Error: error performing http request: Get "https://xxx:8006/api2/json/cluster/resources?type=vm": context deadline exceeded
│
│ with proxmox_vm_qemu.virtual_machine["test-xxx-perf-lv-vm01-01"],
│ on main.tf line 30, in resource "proxmox_vm_qemu" "virtual_machine":
│ 30: resource "proxmox_vm_qemu" "virtual_machine" {
Result: There are lvm leftovers and stale tf state
How to avoid: Set pm_timeout
Example:
provider "proxmox" {
pm_api_url = var.proxmox_api_url
pm_user = var.proxmox_user
pm_password = var.proxmox_password
pm_timeout = 7200
}
Steps to reproduce:
│ Error: task error: task id: UPID:xxx:zzz:zzz:zzz:qmclone:102:somelogin@pam: message: clone failed: lvcreate 'pve-vm01/vm-103-disk-0' error: Failed to activate new LV pve-vm01/vm-103-disk-0.
│
│ with proxmox_vm_qemu.virtual_machine["test-xxx-perf-lv-vm01-02"],
│ on main.tf line 30, in resource "proxmox_vm_qemu" "virtual_machine":
│ 30: resource "proxmox_vm_qemu" "virtual_machine" {
Result: Terraform exits with a non-zero exit code How to avoid: remove leftovers
dmsetup ls | grep 'vm--'
dmsetup remove pve--vm01-vm--102--cloudinit
dmsetup remove pve--vm01-vm--102--disk--1
udevadm settle
Steps to reproduce:
│ Error: api error: code: 500 message: Configuration file 'nodes/xxx/qemu-server/102.conf' does not exist
│
│ with proxmox_vm_qemu.virtual_machine["test-xxx-perf-lv-vm01-01"],
│ on main.tf line 32, in resource "proxmox_vm_qemu" "virtual_machine":
│ 32: resource "proxmox_vm_qemu" "virtual_machine" {
Result: Terraform exits with a non-zero exit code. The problem behind is that during terraform apply Proxmox might move VMs to another host
How to avoid: 2-step vm creation
terraform apply -var='vm_ha_enabled=false'
terraform apply -var='vm_ha_enabled=true'
Steps to reproduce:
Result: wasting of time How to avoid: Create VM template with a small disk & resize it via cloud-init
growpart:
mode: auto
devices:
- /dev/sda3
runcmd:
- [pvresize, /dev/sda3]
- [lvextend, -r, -l, "+100%FREE", /dev/sysvg/root]
We have a fleet of VMs and the vast majority of them is stateless and configuration is well defined via IaC. It means that migration algorithm is quite simple:
To make our life a bit easier we hide complexity inside module. This module creates unified VMs
locals {
somevms = {
worker01 = { num_cpus = 2, memory = 16384, diskSize = "60G", annotation = "xxx" },
worker02 = { num_cpus = 4, memory = 16384, diskSize = "60G", annotation = "xxx" },
worker03 = { num_cpus = 8, diskSize = "160G", annotation = "xxx" },
}
vlanID = xxx
}
resource "proxmox_pool" "ci" {
poolid = "ci"
}
module "awesomevms" {
source = "./modules/proxmox_vm"
for_each = local.somevms
name = each.key
annotation = try(each.value.annotation, "some important note")
num_cpus = try(each.value.num_cpus, null)
memory = try(each.value.memory, null)
vm_ha_enabled = try(each.value.vm_ha_enabled, true)
diskSize = try(each.value.diskSize, "27G")
vlanID = local.vlanID
pool = proxmox_pool.ci.poolid
tags = "xxx;yyy"
}
The interesting thing is that we have a module for VMware and it has almost the same set of params. So, we can simply copy-paste the config(yeah it’s time to mention S.O.L.I.D for IaC).
In current implementations not all parts are finished
Final notes: