Bare-Metal to VM: A Pragmatic P2V Migration to Proxmox
My homelab runs 24+ Docker services on a single Samsung 870 EVO 500 GB SSD — reverse proxies, DNS, SSO, password management, monitoring, media, and more. All of it on bare metal, one kernel panic away from a full outage with no isolation between workloads. This post is about how I moved that entire stack into a Proxmox VE virtual machine without downtime, and why the migration itself is just the beginning of a larger architecture shift.
The Failed Plan: Clonezilla Cold-Clone
The original approach was textbook: boot Clonezilla, image the SSD to an NFS target on the Synology NAS, then restore into a fresh VM disk. Simple in theory.
# Clonezilla boot parameters
ocs-live-restore restoredisk ask_user sda
# Target: NFS share on Synology
mount -t nfs 10.0.0.10:/volume1/docker/clonezilla-images /home/partimag
It failed immediately. The NFS export permissions on the Synology blocked Clonezilla's write attempts — the squashfs live environment runs as a UID that doesn't map cleanly to Synology's NFS permission model. I could have debugged the NFS all_squash and anonuid mappings, but that was a rabbit hole with no guaranteed outcome and a server sitting offline.
Time to pivot.
The Pivot: Direct SSD Passthrough
Instead of imaging and restoring, I passed the physical SSD directly to the VM using its stable /dev/disk/by-id/ path. The existing Ubuntu installation boots as-is — no cloning, no filesystem copy, no intermediate image.
# Create OVMF (UEFI) VM with q35 chipset
qm create 100 --name homelab-recovery --memory 14336 --cores 8 \
--bios ovmf --machine q35 \
--efidisk0 local-lvm:1,efitype=4m,pre-enrolled-keys=0 \
--ostype l26 --cpu host
# Attach the physical SSD by stable disk ID
qm set 100 --sata0 /dev/disk/by-id/ata-Samsung_SSD_870_EVO_500GB_S6PXNX0W614894K
# Set boot order
qm set 100 --boot order=sata0
# Start the VM
qm start 100
The VM booted into the existing Ubuntu 26.04 LTS installation on first try. All Docker containers, systemd services, and configs were intact. The kernel (7.0.0-22-generic) came up cleanly under QEMU/KVM with no driver issues.
Preparing Proxmox: IOMMU, Networking, and Repository Cleanup
Before creating the VM, the Proxmox VE 9.2.3 host (kernel 6.8.12-9-pve → upgraded to 7.0.6-2-pve) needed a few preparations.
IOMMU Passthrough
Enabled IOMMU in GRUB for potential future PCIe passthrough:
# /etc/default/grub
GRUB_CMDLINE_LINUX_DEFAULT="quiet intel_iommu=on iommu=pt"
update-grub && reboot
Enterprise Repository Cleanup
Proxmox 9.x uses deb822 .sources format. Disabled the enterprise repos that require a subscription:
# Disable enterprise repos (no subscription)
mv /etc/apt/sources.list.d/pve-enterprise.sources \
/etc/apt/sources.list.d/pve-enterprise.sources.disabled
mv /etc/apt/sources.list.d/ceph.sources \
/etc/apt/sources.list.d/ceph.sources.disabled
# Full system upgrade
apt update && apt dist-upgrade -y
Dual-Interface Architecture
The homelab needs two isolated networks: a LAN for service traffic and a dedicated NAS link for storage I/O. On bare metal this was two physical NICs. Under Proxmox, it becomes two Linux bridges.
Host Bridge Configuration
# /etc/network/interfaces (Proxmox host)
auto vmbr0
iface vmbr0 inet static
address 192.168.20.99/24
gateway 192.168.20.1
bridge-ports lan
bridge-stp off
bridge-fd 0
auto vmbr1
iface vmbr1 inet static
address 10.0.0.30/24
bridge-ports usbcadptr
bridge-stp off
bridge-fd 0
mtu 9000
VM NIC Assignment
# Attach both networks to VM 100
qm set 100 --net0 virtio=BC:24:11:49:0F:83,bridge=vmbr0
qm set 100 --net1 virtio=BC:24:11:DD:1D:74,bridge=vmbr1,mtu=9000
Guest Netplan (Ubuntu)
# /etc/netplan/00-installer-config.yaml
network:
version: 2
ethernets:
ens18:
match:
macaddress: "bc:24:11:49:0f:83"
addresses:
- 192.168.20.69/24
routes:
- to: default
via: 192.168.20.1
nameservers:
addresses: [1.1.1.1, 9.9.9.9]
ens19:
match:
macaddress: "bc:24:11:dd:1d:74"
addresses:
- 10.0.0.20/24
mtu: 9000
MAC-matching in netplan ensures interface names stay predictable regardless of PCI slot ordering. The guest sees ens18 for LAN and ens19 for NAS without needing fragile set-name rules.
Jumbo Frame Optimization
The NAS link runs at MTU 9000 end-to-end: physical NIC → host bridge → virtio tap → guest interface → NFS client. This reduces per-packet overhead for large sequential transfers (backups, media streaming, bulk reads).
Critical detail: the virtio NIC must have mtu=9000 set explicitly in the QEMU device config. Without it, the guest interface caps at 1500 even if netplan requests 9000:
# This is what made it work — MTU in the QEMU NIC definition
qm set 100 --net1 virtio=BC:24:11:DD:1D:74,bridge=vmbr1,mtu=9000
Verification from inside the guest:
# Confirm MTU 9000 is active on ens19
ip link show ens19 | grep mtu
# 3: ens19: <BROADCAST,MULTICAST,UP> mtu 9000 qdisc fq_codel state UP
# Verify end-to-end jumbo frame path to NAS (8972 + 28 byte header = 9000)
ping -M do -s 8972 -c 3 10.0.0.10
# PING 10.0.0.10 (10.0.0.10) 8972(9000) bytes of data.
# 8980 bytes from 10.0.0.10: icmp_seq=1 ttl=64 time=0.284 ms
NFS Automount Strategy
The guest mounts four NFS shares from the Synology NAS at 10.0.0.10. On bare metal these were hard mounts in /etc/fstab, which caused boot hangs when the NAS interface wasn't ready in time. Under virtualization, the timing problem got worse — the guest's NAS NIC initializes after the mount targets are evaluated.
The fix: x-systemd.automount. The kernel creates the mount point immediately (satisfying any service dependencies), but the actual NFS connection happens lazily on first access.
# /etc/fstab — NFS mounts with systemd automount
10.0.0.10:/volume1/docker /mnt/nas/docker nfs4 x-systemd.automount,x-systemd.idle-timeout=600,nofail,_netdev,rsize=1048576,wsize=1048576 0 0
10.0.0.10:/volume1/media /mnt/nas/media nfs4 x-systemd.automount,x-systemd.idle-timeout=600,nofail,_netdev,rsize=1048576,wsize=1048576 0 0
10.0.0.10:/volume1/backups /mnt/nas/backups nfs4 x-systemd.automount,x-systemd.idle-timeout=600,nofail,_netdev,rsize=1048576,wsize=1048576 0 0
10.0.0.10:/volume1/personal /mnt/nas/personal nfs4 x-systemd.automount,x-systemd.idle-timeout=600,nofail,_netdev,rsize=1048576,wsize=1048576 0 0
# Verify automounts are registered
systemctl list-automounts
# UNIT LOAD ACTIVE SUB DESCRIPTION
# mnt-nas-docker.automount loaded active waiting /mnt/nas/docker
# mnt-nas-media.automount loaded active waiting /mnt/nas/media
# mnt-nas-backups.automount loaded active waiting /mnt/nas/backups
# mnt-nas-personal.automount loaded active waiting /mnt/nas/personal
The nofail flag prevents boot failure if the NAS is offline. The _netdev flag ensures systemd waits for network-online before attempting the mount. Combined with automount, this is a robust pattern for NFS in virtualized environments.
Automated Backups: vzdump to NAS
With the VM running, Proxmox's built-in backup scheduler handles full VM-level backups. The backup target is an NFS share on the Synology, accessed over the dedicated 10 GbE NAS bridge.
Storage Backend
# /etc/pve/storage.cfg (relevant section)
nfs: nas-backups
export /volume1/docker/proxmox-backups
path /mnt/pve/nas-backups
server 10.0.0.10
content backup
prune-backups keep-last=7
Scheduled Job
# /etc/pve/jobs.cfg
vzdump: backup-homelab
enabled 1
schedule 0 2 * * *
storage nas-backups
vmid 100
mode suspend
compress zstd
prune-backups keep-last=7
mailnotification failure
notes-template {{guestname}}-{{node}}-{{vmid}}
Every day at 02:00, the VM is briefly suspended (typically <30 seconds for a 500 GB disk with zstd), a consistent snapshot is written to the NAS, and old backups beyond the last 7 are pruned automatically. Manual runs work identically:
# Manual backup (same parameters as the scheduled job)
vzdump 100 --mode suspend --compress zstd --storage nas-backups
The suspend mode guarantees filesystem consistency without requiring a guest agent. Combined with NAS-level snapshots on the Synology side, this provides two independent recovery paths.
What This Unlocks: The Road Ahead
This migration is not the end state — it's the foundation. Running on bare metal meant every service shared one kernel, one failure domain, and one recovery path. Now that the homelab lives inside Proxmox, the architecture can evolve properly.
Per-Service Migration Analysis
Every one of the 24+ hosted applications will be analyzed individually and migrated to its optimal runtime environment:
- Lightweight services (AdGuard, Unbound, monitoring exporters) → dedicated LXC containers with minimal overhead
- Isolation-critical workloads (SSO, Vaultwarden, databases) → separate KVM VMs with independent kernels
- Stateless app tiers (media frontends, dashboards) → remain in Docker Compose within purpose-built VMs
Minimal Blast Radius
The driving principle: one failure should never cascade. When a reverse proxy crashes, it shouldn't take down the password manager. When a database OOMs, DNS should keep resolving. Proxmox gives this through hard VM/CT boundaries, independent resource limits via cgroups, and separate kernel namespaces.
# Future: per-VM resource capping
qm set 101 --cpulimit 2 --memory 2048 --balloon 1024
qm set 102 --cpulimit 4 --memory 4096 --swap 0
Maximum Recoverability
With NAS-backed storage, recovery is no longer theoretical — it's guaranteed and tested:
- VM-level backups via vzdump → full restore in minutes to any previous daily state
- Proxmox snapshots → instant rollback before risky changes (
qm snapshot 100 --name pre-upgrade) - NAS-side snapshots → Synology's Btrfs snapshots protect the backup store itself
- Per-service volume backups → Docker volume exports to NAS for granular recovery
# Snapshot before any risky operation
qm snapshot 100 --name pre-upgrade --description "Before kernel upgrade"
# If something breaks — instant rollback
qm rollback 100 --snapname pre-upgrade
# List all available recovery points
qm listsnapshot 100
Three layers of backup (vzdump daily, on-demand snapshots, NAS-level protection) mean that no single failure — disk, VM, or operator error — results in permanent data loss. The NAS storage makes this sustainable: 7 daily backups of a 500 GB VM compress to roughly 200 GB with zstd, well within the available capacity.
Future Architecture Targets
- Live migration readiness when a second node is added
- Terraform/OpenTofu provisioning of VM and CT definitions
- Centralized logging (Loki + Alloy) with per-VM log streams
- Network segmentation via VLANs replacing flat bridge topology
Lessons Learned
| Lesson | Detail |
|---|---|
| Pragmatism wins | Direct SSD passthrough was faster, safer, and more reversible than any imaging approach. The server was back online in under 30 minutes. |
| Plan for rollback | The physical SSD is untouched. If the VM fails catastrophically, plug it back into bare metal and boot. Zero-risk migration. |
| MTU must be explicit everywhere | Jumbo frames silently fail if any single hop in the chain defaults to 1500. Verify with ping -M do end-to-end. |
| Automount solves timing | Hard NFS mounts in VMs are a race condition. x-systemd.automount decouples mount availability from boot ordering. |
| Start monolithic, decompose later | Migrating the whole system first preserves uptime. Decomposing into smaller VMs/CTs is the next phase — done safely with snapshots at every step. |
Closing Thought
A P2V migration doesn't have to be a multi-day project with imaging tools and careful partition math. Sometimes the right answer is passing a disk straight through and letting the existing OS boot where it lands. The real value isn't in the migration itself — it's in what comes after: isolation, recoverability, and the freedom to evolve each service independently without risking the whole stack.
The server is no longer a single point of failure. It's a platform.