My lab server was a Lenovo ThinkStation E30 running ESXi 7.0, unpatched since 2020, with a TrueNAS VM sitting on three 4TB WD drives passed through as raw device mappings. About 9TB of data on those drives and no appetite for losing any of it. The goal was Proxmox on the same hardware, TrueNAS CORE upgraded to SCALE, and a way back to ESXi if everything went sideways.
The whole thing worked, every share came back exactly as it was, and Windows didn’t even ask for credentials afterward. Here’s the process, including the parts that fought back.
Figure out how ESXi actually boots before touching anything
The rollback plan depends entirely on where the hypervisor lives. Mine turned out to boot from a 15GB USB stick, which made this whole migration close to zero-risk. SSH into the ESXi box and check:
esxcli system boot device get
ls -l /bootbank
vmkfstools -P /bootbank
esxcli storage core device list
partedUtil getptbl /vmfs/devices/disks/<each device>
The bootbank filesystem UUID maps to a partition on some device. In my case it was mpx.vmhba32:C0:T0:L0, a USB stick, and the partition tables confirmed the SSD and NVMe held nothing but VMFS datastores. That meant my entire rollback plan was: pull the USB stick, label it, and put it in a drawer. If ESXi boots from the same SSD you want Proxmox on, you need a different plan than this one.
One quirk worth knowing: ESXi 7’s FIPS sshd rejects ed25519 keys. Use RSA for the ESXi side. And its busybox has no tr command, which cost me an authorized_keys file once.
Map your disks to serial numbers
If your NAS VM uses raw device mappings, verify exactly which physical disk backs each pool member before anything gets unplugged. The vml IDs in the RDM files decode to disk serials:
ls -la /vmfs/volumes/SSD/ # wherever your RDM pointer files live
esxcli storage core device list # match serials to devices
Write the serials down. Mine were three WD Red 4TB drives, and those exact serials show up again later on the Proxmox side as /dev/disk/by-id/ata-WDC_WD40EZRZ-22GXCB0_WD-XXXXXXXX. ZFS pools are self-describing, so they survive a hypervisor swap fine, but only if you never let an installer or a storage wizard initialize those disks.
Back up everything that can’t be re-created
The datastores get wiped, so anything you want copies of has to leave the box first. My list:
ESXi host config, in case I ever need to rebuild it:
vim-cmd hostsvc/firmware/sync_config
vim-cmd hostsvc/firmware/backup_config
scp root@esxi:/scratch/downloads/<uuid>/configBundle-*.tgz ./
TrueNAS config database plus the secret seed. These two files are the entire NAS identity: users, shares, permissions, the SMB SID that keeps Windows ACLs working:
scp root@truenas:/data/freenas-v1.db ./
scp root@truenas:/data/pwenc_secret ./
I also dumped a full settings inventory with midclt so I’d have something to diff against after the migration. Shares, users, NFS exports, static routes, scrub schedules, service list, dataset ACLs. That inventory caught things I’d forgotten existed, like three enabled NFS exports and NTLMv1 auth for some old client. If the goal is “everything identical afterward,” you need a before picture.
Blue Iris ran in a Windows VM I chose not to migrate. Its entire config lives in the registry, so one export covers it:
reg export "HKLM\SOFTWARE\Perspective Software\Blue Iris" C:\BI-settings-backup.reg /y
Copy that to the NAS share and it rides the pools across the migration for free.
Export the pools cleanly
This is the step that protects the data. A cleanly exported pool imports anywhere without complaint. The catch on TrueNAS is that the system dataset usually lives on one of your pools and pins it, and syslog holds the system dataset open. The dance that worked:
midclt call systemdataset.update '{"pool": "freenas-boot"}'
zpool export WD4TB1
zpool export WD4TB2
zpool export WD4TB3
Two gotchas. The systemdataset.update call returns a job number and finishes in the background, so wait and confirm with midclt call systemdataset.config before exporting. And if the export still says the pool is busy, it’s syslog; the export went through on a retry loop about 40 seconds after the dataset move. Then shut the NAS down, shut remaining VMs down, and power off the host:
vim-cmd vmsvc/getallvms
vim-cmd vmsvc/power.shutdown <vmid>
vim-cmd hostsvc/maintenance_mode_enter
esxcli system shutdown poweroff -r migration
Pull the ESXi USB stick and label it. That’s the rollback done.
Install Proxmox
Write the Proxmox ISO to a USB stick in raw dd mode, not file-copy mode, and verify the SHA256 both after downloading and ideally after writing. I unplugged the three WD drives during the install so the target disk list only showed the SSD and NVMe. Probably unnecessary. Did it anyway. A wrong click on this screen is the only way this migration eats data.
Installer choices that mattered: target was the old 160GB datastore SSD, the NVMe stays untouched for VM storage, the box got ESXi’s old IP, and I left “pin network interface names” checked. That pinning matters more than it looks. Removing a PCIe card later can shuffle PCI addresses, rename your NIC, and break the bridge config on a headless box.
Post-install, switch the repos and update (Proxmox 9 uses deb822 style sources):
rm /etc/apt/sources.list.d/pve-enterprise.sources /etc/apt/sources.list.d/ceph.sources
cat > /etc/apt/sources.list.d/pve-no-subscription.sources <<'EOF'
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: trixie
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF
apt update && apt full-upgrade -y
Then turn the old NVMe datastore into VM storage:
sgdisk --zap-all /dev/nvme0n1
wipefs -a /dev/nvme0n1
vgcreate nvme1tb /dev/nvme0n1
lvcreate -l 98%FREE --type thin-pool -n vmdata nvme1tb
pvesm add lvmthin nvme1tb --vgname nvme1tb --thinpool vmdata --content images,rootdir
Build the TrueNAS VM with the old MAC
Reusing the old VM’s MAC address means DHCP reservations, firewall rules, and anything else keyed to it never notice the swap. Grab the MACs from the old VMX files before the wipe (grep generatedAddress *.vmx), then:
qm create 100 --name truenas --memory 6144 --cores 2 --cpu host \
--net0 virtio=00:0C:29:AA:BB:CC,bridge=vmbr0 \
--scsihw virtio-scsi-single --scsi0 nvme1tb:32 --ostype l26 --onboot 1
Install TrueNAS SCALE into that VM with only the 32GB virtual boot disk attached. I deliberately did not attach the WD drives yet, so the SCALE installer had exactly one disk to choose from and zero chance of touching a pool member. On a SeaBIOS VM, answer No to the installer’s EFI question.
After the install finishes, detach the ISO and pass the physical disks through by ID:
qm set 100 --delete ide2 --boot order=scsi0 \
--scsi1 /dev/disk/by-id/ata-WDC_WD40EZRZ-22GXCB0_WD-XXXXXXX1 \
--scsi2 /dev/disk/by-id/ata-WDC_WD40EZRZ-22GXCB0_WD-XXXXXXX2 \
--scsi3 /dev/disk/by-id/ata-WDC_WD40EZAZ-00SF3B0_WD-XXXXXXX3
Always by-id, never /dev/sdX. Device letters shuffle; serial numbers don’t.
Import the CORE config into SCALE
TrueNAS supports uploading a CORE 13 config into SCALE, and it carries users, shares, permissions, schedules, and the SMB SID. What it can’t fix on its own is the network interface name. My CORE config had the static IP bound to vmx0, a VMware driver name that doesn’t exist on Linux. Rather than fix it at the console afterward, I patched the config copy before uploading. The fresh SCALE install told me its interface name (ens18), and one sqlite update later the config was ready:
import sqlite3
c = sqlite3.connect('freenas-v1.db')
c.execute("UPDATE network_interfaces SET int_interface='ens18' WHERE int_interface='vmx0'")
c.commit()
Then tar the database together with the secret seed and upload it. The file names inside the tar matter:
tar cf config.tar freenas-v1.db pwenc_secret
curl -sk -u truenas_admin:TEMP_PASSWORD \
-F 'data={"method": "config.upload", "params": []}' \
-F 'file=@config.tar' https://<scale-ip>/_upload
One API note that cost me a failed attempt: on SCALE 25.04 the obvious endpoint /api/v2.0/config/upload rejects the documented data argument with “too many arguments.” The generic /_upload endpoint with the method envelope shown above is what works. The box applies the config and reboots itself, and mine came back on its old static IP with the old hostname like nothing happened.
Two things did not survive the import, both reasonable. The SSH service came back disabled since it wasn’t in my enabled service list, and root’s authorized_keys was gone because that file lived on the old CORE boot pool, not in the config database. Both are two-minute fixes from the VM console:
midclt call service.update ssh '{"enable": true}'
midclt call service.start ssh
Verify against the before picture
The pools imported automatically on first boot after the config load. Then I diffed reality against the inventory I’d taken on CORE:
zpool list
midclt call sharing.smb.query
midclt call sharing.nfs.query
midclt call user.query '[["builtin","=",false]]'
midclt call smb.config # check netbiosname, workgroup, server_sid
midclt call staticroute.query
midclt call pool.scrub.query
Everything matched, and the item I cared about most, the SMB server SID, came through identical. That SID is why my Windows machines connected with cached credentials and never showed a prompt. If it changes, every ACL mapping breaks and Windows treats the server as a stranger.
The final test was just opening the shares in Explorer. Same shares, same files, same hidden share still hidden, and the NAS never found out it spent the afternoon getting a brain transplant onto different silicon. The old ESXi stick is in the drawer if it ever needs to come back out.
