[TUTORIAL] Proxmox LXC to VM Converter

ArMaTeC

New Member
Feb 12, 2026
9
11
3
A robust Bash toolkit that converts Proxmox LXC containers into fully bootable QEMU/KVM virtual machines — directly on your Proxmox VE host. Includes a companion disk-shrink script and handles disk creation, filesystem copy, kernel/GRUB installation, networking reconfiguration, and VM provisioning automatically.

Table of Contents


Scripts

This project includes two scripts:

ScriptPurpose
lxc-to-vm.shConverts an LXC container into a bootable VM. Main script.
shrink-lxc.shShrinks an LXC container's disk to used space + headroom. Standalone or use --shrink in lxc-to-vm.sh.


Features

Conversion (lxc-to-vm.sh)

  • One-command conversion — turn any LXC into a bootable VM
  • Multi-distro support — Debian, Ubuntu, Alpine, CentOS/RHEL/Rocky, Arch Linux (auto-detected)
  • BIOS & UEFI boot — MBR/SeaBIOS (default) or GPT/OVMF with --bios ovmf
  • Integrated disk shrink--shrink shrinks the LXC disk before conversion, auto-sets disk size
  • Dry-run mode — preview every step without making changes (--dry-run)
  • Network preservation — keep original network config with --keep-network, or replace with DHCP on ens18
  • Auto-start & health checks — boot the VM and verify guest agent, IP, and reachability (--start)
  • Post-conversion validation — automatic 6-point check (disk, boot order, network, agent, EFI)
  • Interactive & non-interactive modes — use CLI flags for scripting or answer prompts manually
  • Auto-dependency installation — missing tools (parted, kpartx, rsync, etc.) installed automatically
  • Input & storage validation — catches invalid IDs, missing storage, format errors before work begins
  • Smart disk space management — checks available space, auto-selects mount points with room, explains LVM vs filesystem constraints
  • Custom working directory--temp-dir to place the temporary disk image on any mount point
  • LXC config inheritance — memory, CPU cores pulled from the source container config
  • Serial console support — enables ttyS0 serial console for Proxmox terminal access
  • Colored output — color-coded progress messages (auto-disabled when piped)
  • Full logging — all operations logged to /var/log/lxc-to-vm.log
  • Safe cleanup — trap-based cleanup removes temp files and loop devices on exit or error
  • Wizard mode — interactive TUI with progress bars and guided setup (--wizard)
  • Parallel batch processing — run N conversions concurrently (--parallel N)
  • Pre-flight validation — comprehensive checks without converting (--validate-only)
  • Cloud/remote export — export VM disks to S3, NFS, or SSH destinations (--export-to)
  • VM template creation — convert to Proxmox template with optional sysprep (--as-template)
  • Auto-rollback — automatically restore container if conversion fails (--rollback-on-failure)
  • Configuration profiles — save and reuse common conversion settings (--save-profile, --profile)
  • Resume capability — resume interrupted conversions from partial state (--resume)
  • Auto-cleanup — destroy original LXC after successful conversion (--destroy-source)

Disk Shrinker (shrink-lxc.sh)

  • Shrinks LXC disks to actual usage + configurable headroom
  • Smart minimum detection — queries resize2fs -P for the true filesystem minimum size
  • Auto-retry — if resize2fs fails, automatically increments by 2GB and retries (up to 5 attempts)
  • Multi-backend — supports LVM-thin, LVM, directory (raw/qcow2), and ZFS storage
  • Dry-run mode — preview the shrink plan without making changes
  • Safety confirmation — prompts before destructive operations
  • Prints ready-to-use conversion command after shrinking

Supported Distros


Distro FamilyDetected IDsPackage ManagerNotes
Debian/Ubuntudebian, ubuntu, linuxmint, pop, kaliaptPrimary target, most tested
AlpinealpineapkOpenRC init system configured automatically
RHEL/CentOScentos, rhel, rocky, alma, fedorayum/dnfKernel + GRUB2
Arch Linuxarch, manjaro, endeavourospacmanKernel + GRUB
Distro is auto-detected from /etc/os-release inside the container. The script uses the appropriate package manager and bootloader installation commands for each family.

Requirements


RequirementDetails
Proxmox VEVersion 7.x, 8.x or 9.x
Source LXCDebian, Ubuntu, Alpine, CentOS/RHEL/Rocky, or Arch based container
Root accessScripts must run as root on the Proxmox host
Free disk spaceFilesystem space ≥ disk image size (LVM/ZFS storage cannot be used as temp space — see Disk Space Management)
NetworkInternet access (to install kernel/GRUB packages inside chroot)

Dependencies (auto-installed by lxc-to-vm.sh)

  • parted — disk partitioning
  • kpartx — partition mapping for loop devices
  • rsync — filesystem copy
  • e2fsprogs — ext4 formatting and filesystem tools (mkfs.ext4, resize2fs, e2fsck)
  • dosfstools — FAT32 formatting for UEFI ESP (only when using --bios ovmf)

For more details see https://github.com/ArMaTeC/lxc-to-vm

 
Last edited:

[6.0.6] - 2025-02-26​

Fixed (CentOS 7 Support)​

  • CentOS 7 EOL repos: Auto-fix CentOS 7 repos to use vault.centos.org after EOL (June 2024)
  • CentOS 7 GRUB: Fix linuxefi/initrdefi commands in grub.cfg for BIOS boot
  • pcspkr blacklist: Add module_blacklist=pcspkr kernel parameter to suppress PC speaker driver errors during boot

[6.0.5] - 2025-02-25​

Fixed (CentOS/RHEL Support)​

  • CPU type fix: Added --cpu host to qm create for x86-64-v2 compatibility (CentOS 9 glibc requires x86-64-v2, not supported by default kvm64)
  • Initramfs drivers: Added sd_mod and ext4 to dracut --add-drivers so root block device is created properly
  • LXC artifact cleanup: Remove LXC-specific systemd generators, container-getty services, and masked mount units that break VM boot
  • Library cache: Run ldconfig before dracut so libsystemd-core is found and included in initramfs
  • Login prompt: Enable getty@tty1 and serial-getty@ttyS0 services (containers use container-getty which doesn't work in VMs)
  • Guest agent: Comment out restrictive FILTER_RPC_ARGS allow-list in CentOS qemu-ga config so guest-exec works

Fixed (General)​

  • PowerShell 5.1 compatibility: Use $psi.Arguments string instead of .ArgumentList collection in Invoke-ExternalCommandWithTimeout
  • CRLF stripping after SCP to remote host for bash script compatibility

[6.0.4] - 2025-02-22​

Added​

  • Added missing dump_system_info function for debug output
  • Added PayPal donation link for project support

Fixed​

  • Fixed ShellCheck warnings with proper exclusions (SC2155, SC2046, SC2221, SC2222, SC2064)
  • Fixed missing LOG_FILE variable definition
  • Fixed missing debug function implementation
  • Removed duplicate dump_system_info call and relocated after function definition
  • Removed stray return statement outside function scope causing script exit before VM import

[6.0.3] - 2025-10-22​

Added

  • Enhanced debug output with detailed phase comments
  • GitHub workflows for release, shellcheck, and bash-syntax checks
  • Buy Me A Coffee support section
Fixed

  • FEATURE_C12 ext4 boot error fixes
  • ext4 metadata_csum disabling at mkfs.ext4 creation time
  • Filesystem writability test before import
  • Host-side BIOS grub-install fallback for loop device errors
  • Ownership normalization for unprivileged LXC ID remapping


[6.0.2] - 2025-10-16​

Fixed

  • Fixed FEATURE_C12 boot error on older kernels
  • Fixed cleanup trap for better resource management
  • Metadata_csum feature disabled to prevent busybox initramfs boot failures

https://github.com/ArMaTeC/lxc-to-vm/releases/tag/v6.0.6


Full changelog
https://github.com/ArMaTeC/lxc-to-vm/blob/main/CHANGELOG.md
 
Last edited:
I wanted to migrate my OpenClaw LXC to a VM and asked Perplexity for advice. The first method that came up was your script. It took only a few minutes, and the VM was ready and running without any issues. Well done!
 
Another quick update

Added (Disk Management Suite)​

  • expand-lxc.sh: New script to expand LXC container root disk
    • Expansion modes: absolute size (-s), add GB (-a), percent of pool (--percent), max available (--max)
    • Hot-expand support (--no-restart) for LVM, LVM-thin, ZFS, and raw directory storage
    • Safety margins for --max mode (--safety-margin, --safety-percent)
    • Dry-run mode (--dry-run) for change preview
    • Structured exit codes and mapped error messages
    • Debug mode via EXPAND_LXC_DEBUG=1, log at /var/log/expand-lxc.log
  • expand-vm.sh: New script to expand VM primary disk
    • Same expansion modes as expand-lxc.sh
    • Hot-expand support (--hot-expand) via QEMU monitor (block_resize)
    • Supports QCOW2 and raw image formats
    • Dry-run mode (--dry-run) for change preview
    • Structured exit codes and mapped error messages
    • Debug mode via EXPAND_VM_DEBUG=1, log at /var/log/expand-vm.log
  • shrink-vm.sh: New script to shrink VM disk to actual usage
    • Measures real data usage via virt-df (libguestfs) or qemu-img
    • Optional virt-resize path (-u/--use-libguestfs) for complex layouts
    • Configurable headroom (-g, default 2GB) and metadata margin (5%, min 512MB)
    • Minimum disk size enforced (2GB)
    • Dry-run mode (--dry-run) for change preview
    • Supports LVM-thin, LVM, Directory (QCOW2/raw), ZFS
    • Debug mode via SHRINK_VM_DEBUG=1, log at /var/log/shrink-vm.log
  • clone-replace-disk.sh: New script to clone and replace VM/LXC disks
    • Supports both VM (-t vm) and LXC (-t lxc) targets
    • Optional resize during clone (--size)
    • Cross-storage cloning (e.g., LVM-thin → ZFS, Directory → LVM-thin)
    • Format conversion during clone (--format raw|qcow2)
    • Original disk kept by default; removed only with --remove-old
    • Optional VM snapshot before operations (--snapshot)
    • Automatic rollback of config on clone/replace failure
    • Debug mode via CLONE_REPLACE_DEBUG=1, log at /var/log/clone-replace-disk.log

Added (Documentation)​

  • docs/expand-lxc.md — Full reference guide for expand-lxc.sh
  • docs/expand-vm.md — Full reference guide for expand-vm.sh
  • docs/shrink-vm.md — Full reference guide for shrink-vm.sh
  • docs/clone-replace-disk.md — Full reference guide for clone-replace-disk.sh
  • Updated docs/Home.md, docs/_Sidebar.md, docs/Installation.md to cover all 7 scripts
  • Updated docs/Troubleshooting.md with sections for all new scripts and debug table
  • Updated docs/API-Automation.md with automation examples for disk management scripts
 
  • Like
Reactions: reinob
Added experimental VM to LXC converter

Tried this (in a virtual & isolated PVE environment!) with a VM that runs a Debian 13.5 server + Tailsacle + Joplin server.

However this failed at the CT creation stage. I'm pretty sure the reason is the Docker overlays parts. See this sample log for example:

Code:
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.worker.js: Wrote only 7680 of 10240 bytes
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.js.map: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.js: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.min.js: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.sandbox.js.map: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.sandbox.js: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.worker.entry.js: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/webpack.js: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/legacy: Cannot mkdir: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/legacy: Cannot mkdir: No space left on device

And before you assume this is disk-space related; it is NOT! I had 60GB free in the root directory (local storage) & another 50GB free in the local-lvm target storage for the CT. The VM has a 10GB disk (mostly empty) & the estimated CT size was to be 2GB.

I don't think your script is to blame, as I've had similar results when trying to recreate a tarball that contains docker/overlays on Proxmox LXC.

I gave your script a shot-in-the-dark, guess I'm just not lucky!
 
Tried this (in a virtual & isolated PVE environment!) with a VM that runs a Debian 13.5 server + Tailsacle + Joplin server.

However this failed at the CT creation stage. I'm pretty sure the reason is the Docker overlays parts. See this sample log for example:

Code:
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.worker.js: Wrote only 7680 of 10240 bytes
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.js.map: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.js: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.min.js: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.sandbox.js.map: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.sandbox.js: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/build/pdf.worker.entry.js: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/webpack.js: Cannot write: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/legacy: Cannot mkdir: No space left on device
tar: ./var/lib/docker/overlay2/b7ed824d6c9c27e275ed242c5e8773faa2fd1c4747b909797eaebec0735a5134/diff/home/joplin/packages/lib/node_modules/pdfjs-dist/legacy: Cannot mkdir: No space left on device

And before you assume this is disk-space related; it is NOT! I had 60GB free in the root directory (local storage) & another 50GB free in the local-lvm target storage for the CT. The VM has a 10GB disk (mostly empty) & the estimated CT size was to be 2GB.

I don't think your script is to blame, as I've had similar results when trying to recreate a tarball that contains docker/overlays on Proxmox LXC.

I gave your script a shot-in-the-dark, guess I'm just not lucky!

If the VM is smallish and can be shared with me (Won't need your login), I'll be more than happy to give it a look over to see if it can be done in an automated way (Can't promise anything) but 100% worked the last time i did Debian vm to ctx.

Alternatively, you could try to install zip and temp compress up the overlay2 folder and clean up the original content, then convert to a CTX and decompress the zip you created always backup before making any changes.

Bash:
sudo apt update && sudo apt install zip -y
sudo zip -r ~/docker_backup.zip /var/lib/docker/overlay2/
sudo systemctl stop docker
sudo systemctl stop docker.socket
sudo rm -rf /var/lib/docker/overlay2/*

Shutdown the server
Convert VM to CTX

Bash:
sudo systemctl stop docker
sudo systemctl stop docker.socket
*******************************
cd /var/lib/docker/overlay2/
sudo unzip ~/docker_backup.zip -d /
*******************************
sudo mkdir -p l
sudo find . -maxdepth 2 -name diff | awk -F/ '{print $2}' | xargs -I '{}' -- sh -c 'echo -n "ln -sf ../{}/diff l/"; cat {}/link;
echo' | sudo sh
sudo chown -R root:root /var/lib/docker/overlay2/
sudo systemctl start docker

docker images
docker ps -a
 
Last edited:
Bash:
sudo mkdir -p l
sudo find . -maxdepth 2 -name diff | awk -F/ '{print $2}' | xargs -I '{}' -- sh -c 'echo -n "ln -sf ../{}/diff l/"; cat {}/link;
echo' | sudo sh
Could you please clarify what these lines of code are for.
 
Bash:
sudo mkdir -p l
sudo find . -maxdepth 2 -name diff | awk -F/ '{print $2}' | xargs -I '{}' -- sh -c 'echo -n "ln -sf ../{}/diff l/"; cat {}/link;
echo' | sudo sh
Could you please clarify what these lines of code are for.
It to rebuild the symbolic links if any

  1. sudo mkdir -p l
    • Creates a directory named l (likely shorthand for "links") if it doesn't already exist.
  2. sudo find . -maxdepth 2 -name diff
    • Searches the current directory and its immediate subdirectories (up to 2 levels deep) for any file or folder named diff.
  3. awk -F/ '{print $2}'
    • Takes the path of the found file (e.g., ./subdir_name/diff) and extracts the name of the subdirectory (the part between the first and second /).
  4. xargs -I '{}' -- sh -c '...'
    • For every subdirectory name found, it runs a small internal shell script.
    • echo -n "ln -sf ../{}/diff l/": Starts building a symbolic link command. It points the link to the generic diff file inside the subdirectory.
    • cat {}/link: Reads the content of a file named link inside that same subdirectory. This file contains the "real" name the user wants the link to have (e.g., fix-bug.patch).
    • echo: Adds a newline to finish the command string.
  5. | sudo sh
    • Takes all the constructed commands (e.g., ln -sf ../subdir_name/diff l/fix-bug.patch) and executes them with root privileges.

 
  • Like
Reactions: gfngfn256
OK, I decided to give it a bash.

Note the following:

zip -r ~/docker_backup.zip /var/lib/docker/overlay2/
This eventually crashes the system, so I changed it to:
Code:
tar --sparse -cf - /var/lib/docker/overlay2/ | pigz > ~/docker_backup.tar.gz
Not a problem.

However, using the interactive ./vm-to-lxc.sh it calculated a container size of 2GB which failed with:
Code:
extracting archive '/vm-to-lxc-112/rootfs.tar.gz'
tar: ./root/docker_backup.tar.gz: Cannot write: No space left on device
tar: ./root/get-docker.sh: Cannot write: No space left on device
tar: ./root/joplin-server: Cannot mkdir: No space left on device
tar: ./root/joplin-server: Cannot mkdir: No space left on device
tar: ./root/joplin-server/docker-compose.yml: Cannot open: No such file or directory
tar: ./root/.bashrc: Cannot write: No space left on device
tar: ./root/.local: Cannot mkdir: No space left on device
tar: ./root/.local: Cannot mkdir: No space left on device
tar: ./root/.local/share: Cannot mkdir: No such file or directory
tar: ./root/.local/share: Cannot mkdir: No such file or directory
tar: ./root/.local/share/nano: Cannot mkdir: No such file or directory
etc. etc.
So I then decided to use:
Code:
./vm-to-lxc.sh -v 112 -c 160 -s local-lvm -d 10

However this also failed to create the container with:
Code:
extracting archive '/var/lib/vz/dump/vm-to-lxc-112/rootfs.tar.gz'
tar: ./var/lib/docker/volumes/backingFsBlockDev: Cannot mknod: Operation not permitted

I guess this "experiment" to convert a VM running a docker container to an LXC is reaching its end!
(I once tried myself in the past - and failed on similar areas, if I remember correctly).
 
OK, I decided to give it a bash.

Note the following:


This eventually crashes the system, so I changed it to:
Code:
tar --sparse -cf - /var/lib/docker/overlay2/ | pigz > ~/docker_backup.tar.gz
Not a problem.

However, using the interactive ./vm-to-lxc.sh it calculated a container size of 2GB which failed with:
Code:
extracting archive '/vm-to-lxc-112/rootfs.tar.gz'
tar: ./root/docker_backup.tar.gz: Cannot write: No space left on device
tar: ./root/get-docker.sh: Cannot write: No space left on device
tar: ./root/joplin-server: Cannot mkdir: No space left on device
tar: ./root/joplin-server: Cannot mkdir: No space left on device
tar: ./root/joplin-server/docker-compose.yml: Cannot open: No such file or directory
tar: ./root/.bashrc: Cannot write: No space left on device
tar: ./root/.local: Cannot mkdir: No space left on device
tar: ./root/.local: Cannot mkdir: No space left on device
tar: ./root/.local/share: Cannot mkdir: No such file or directory
tar: ./root/.local/share: Cannot mkdir: No such file or directory
tar: ./root/.local/share/nano: Cannot mkdir: No such file or directory
etc. etc.
So I then decided to use:
Code:
./vm-to-lxc.sh -v 112 -c 160 -s local-lvm -d 10

However this also failed to create the container with:
Code:
extracting archive '/var/lib/vz/dump/vm-to-lxc-112/rootfs.tar.gz'
tar: ./var/lib/docker/volumes/backingFsBlockDev: Cannot mknod: Operation not permitted

I guess this "experiment" to convert a VM running a docker container to an LXC is reaching its end!
(I once tried myself in the past - and failed on similar areas, if I remember correctly).
very odd if you can create a test VM so I can reproduce the issue on my test rig thats the only way I can try and find a solution or work out if its even possible I've tested this with all my VM's and with a few exceptions due to how the kernal interacts disks
 
Tried one with Debian VM & docker?
just pushed an update might not be perfect as there will be some remediation steps to repull down the docker images but configs should stay in place

Changes

Added exclusions for container runtime storage directories to both the rsync copy and the du size calculation:

  • --exclude='/var/lib/docker/*' (Docker overlay2/image layers)
  • --exclude='/var/lib/containerd/*' (containerd/Kubernetes data)
  • --exclude='/var/lib/containers/*' (Podman/CRI-O data)
This prevents:

  • Temp disk exhaustion during tar creation
  • Oversized container disk recommendations
  • Attempting to migrate non-portable Docker layer data (which should be rebuilt on the target container anyway)
Docker containers and images should be recreated on the LXC container after migration; the overlay2 filesystem data is not safely portable across host environments.