Anatomy of a VM script
A Proxmox VE VM script explained step by step, with the engine helpers it calls and their signatures.
This page follows vm/ubuntu-vm.sh
from the first line to the last. It is the script that uses the most engine
helpers, so it shows the current way of doing each step. Where other scripts
still do a step by hand, the difference is pointed out.
Everything here is Proxmox VE. The Incus scripts are much shorter and are covered at the end. The functions themselves are documented on the VM engine page.
The steps at a glance
| Step | In ubuntu-vm.sh | Engine function |
|---|---|---|
| 1 | Load the engine | load_functions |
| 2 | Identity, traps, working directory | error_handler, cleanup |
| 3 | Host checks and tools | vm_preflight, vm_require_tools |
| 4 | Defaults and the wizard | vm_start_script, vm_prompt_* |
| 5 | Cloud-Init question | vm_prompt_cloud_init |
| 6 | Storage and disk names | vm_select_storage, vm_define_disk_references |
| 7 | Download the image | vm_image_cache_path, vm_fetch_image |
| 8 | Prepare the image | vm_prepare_cloud_image, vm_expand_image |
| 9 | Create the VM | vm_claim_vmid, qm create, vm_mark_created |
| 10 | EFI disk, import, attach | vm_alloc_efi_disk, vm_import_disk, qm set |
| 11 | Description and resize | set_description, vm_resize_disk |
| 12 | Provision | vm_provision |
| 13 | Start and report | vm_start_vm, vm_wait_for_ip, vm_print_summary, vm_finish |
1. Loading the VM engine
COMMUNITY_SCRIPTS_URL="${COMMUNITY_SCRIPTS_URL:-https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main}"
source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/pve/vm-core.func")
load_functionsA VM script loads one file, pve/vm-core.func. That file pulls in
host/validate.func and vm/common.func itself and turns on
set -euo pipefail. vm/cloud-init.func is loaded later, only when Cloud-Init
is used.
load_functions sets the colours, icons and STD, loads the telemetry
functions and checks that the shell is Bash. It does not run any host check;
the script does that itself in step 3.
pve/vm-core.func is also a dispatcher: on a host without pveversion but
with a working incus, it loads incus/vm-core.func instead and returns.
2. Identity, traps and working directory
APP="Ubuntu"
APP_TYPE="vm"
NSAPP="ubuntu-vm"
GEN_MAC=02:$(openssl rand -hex 5 | awk '{print toupper($0)}' | sed 's/\(..\)/\1:/g; s/.$//')
RANDOM_UUID="$(cat /proc/sys/kernel/random/uuid)"
METHOD=""
var_os="ubuntu"
var_version="26.04"
THIN="discard=on,ssd=1,"
USE_CLOUD_INIT="no"
set -Eeo pipefail
shopt -s inherit_errexit
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
trap cleanup EXIT
trap 'post_update_to_api "failed" "130"' SIGINT
trap 'post_update_to_api "failed" "143"' SIGTERM
trap 'post_update_to_api "failed" "129"; exit 129' SIGHUP
TEMP_DIR=$(mktemp -d)
pushd "$TEMP_DIR" >/dev/null| Variable | Used for |
|---|---|
APP | Name in the header, messages, summary and description panel |
APP_TYPE | Header lookup (headers/vm/<slug>) |
NSAPP | Slug for the script page link in the description panel |
GEN_MAC | Random locally administered MAC, the default for the NIC |
RANDOM_UUID, var_os, var_version, METHOD | Telemetry |
THIN | Disk options for block storage. vm_select_storage clears it on file storage. |
error_handler reports the failed line and command, then calls cleanup_vmid,
which stops and destroys the VM. cleanup runs on every exit: it leaves and
removes TEMP_DIR and sends the final telemetry status if nobody did.
The image is downloaded to a cache, but all work on it happens in TEMP_DIR.
3. Host checks and tools
vm_preflight
vm_require_tools virt-customizevm_preflight runs once, in this order:
| Check | Stops when | Exit code |
|---|---|---|
check_root | Not root, or started through sudo | 104 |
arch_check | Host is not amd64. Skipped when the script set var_arm64="yes". | plain exit |
pve_check | Proxmox VE is not 8.0 to 8.9 or 9.0 to 9.2 | 105 |
kvm_check | /dev/kvm is missing | 236 |
ssh_check | You answer "No" to the SSH warning. Skipped when unattended. | plain exit |
vm_require_tools installs missing host tools with apt-get before anything is
created. It knows jq, curl, pv, unzip, zstd, bzip2, gzip,
openssl, wget, rsync, gunzip, xz, 7z, virt-customize,
virt-resize, virt-filesystems and guestfish.
A script whose upstream publishes one architecture only says so before the
checks. From vm/archlinux-vm.sh:
vm_require_arch amd64A script that supports ARM hosts sets var_arm64="yes" (vm/docker-vm.sh,
vm/pimox-haos-vm.sh) and builds its URLs with vm_arch_resolve.
4. Defaults and the wizard
Every script defines two functions and hands them to vm_start_script:
function default_settings() {
select_os "${VM_OS_VERSION:-}"
VMID=$(get_valid_nextid)
vm_apply_machine_type "q35"
DISK_SIZE="7G"
DISK_CACHE=""
HN="ubuntu"
CPU_TYPE=""
CORE_COUNT="2"
RAM_SIZE="2048"
BRG="vmbr0"
MAC="$GEN_MAC"
VLAN=""
MTU=""
START_VM="yes"
METHOD="default"
# ... prints the summary
}
function advanced_settings() {
METHOD="advanced"
select_os
vm_prompt_vmid "${VMID:-$(get_valid_nextid)}"
vm_prompt_machine_type "q35"
vm_prompt_disk_size "${DISK_SIZE:-7G}" "Set Disk Size in GiB (e.g., 10, 20)"
vm_prompt_disk_cache "none"
vm_prompt_hostname "ubuntu"
vm_prompt_cpu_model "kvm64"
vm_prompt_cpu_cores "2"
vm_prompt_ram "2048"
vm_prompt_bridge "vmbr0"
vm_prompt_mac "$GEN_MAC"
vm_prompt_vlan
vm_prompt_mtu
vm_prompt_keyboard
vm_prompt_verbose "no"
vm_prompt_start_vm "yes"
if vm_confirm_advanced_settings "Ready to create a ${APP}?"; then
echo -e "${CREATING}${BOLD}${DGN}Creating a ${APP} using the above advanced settings${CL}"
else
header_info
echo -e "${ADVANCED}${BOLD}${RD}Using Advanced Settings${CL}"
advanced_settings
fi
}
vm_start_script "Use Default Settings?\n\nDefaults:\n• 2 CPU Cores\n• 2 GB RAM\n• 7 GB Disk" 13 58vm_start_script runs vm_preflight (a no-op the second time), checks that
the terminal can show dialogs, and asks Use Default Settings? with the
buttons Yes and Advanced. Yes calls default_settings; Advanced calls
advanced_settings. On the default path it then lowers CORE_COUNT to the
number of host CPU threads if the script asked for more.
The variables are plain script variables, not var_*. Several of them are not
values but ready-made fragments of the qm command line:
| Variable | Example | Meaning |
|---|---|---|
VMID | 105 | From get_valid_nextid or the wizard |
HN | ubuntu | VM name |
CORE_COUNT, RAM_SIZE | 2, 2048 | Cores and MiB |
DISK_SIZE | 7G | Always a number followed by G |
DISK_CACHE | empty or cache=writethrough, | Fragment for the disk option string |
MACHINE | empty or -machine q35 | Fragment for qm create |
FORMAT | empty or ,efitype=4m | Fragment appended to -efidisk0 |
CPU_TYPE | empty or -cpu host | Fragment for qm create |
BRG, MAC | vmbr0, 02:AB:... | Bridge and MAC of net0 |
VLAN, MTU | empty or ,tag=20, ,mtu=9000 | Fragments appended to -net0 |
START_VM | yes | Start after creation |
METHOD | default, advanced, unattended | Which path was taken |
Every wizard question
The wizard is whatever advanced_settings calls, in that order. These are the
engine's prompts. The default column is the function's own default; a script
usually passes its own as the first argument.
| Function | Dialog title | Choices or input | Default | Sets |
|---|---|---|---|---|
vm_prompt_vmid [id] | VIRTUAL MACHINE ID | Number from 100 to 999999999, must be free | Next free ID | VMID |
vm_prompt_machine_type [type] | MACHINE TYPE | i440fx, q35 | i440fx | MACHINE_TYPE, MACHINE, FORMAT |
vm_prompt_disk_size [size] [message] | DISK SIZE | Number, optional G | 8G | DISK_SIZE |
vm_prompt_disk_cache [mode] | DISK CACHE | None, Write Through | none | DISK_CACHE |
vm_prompt_hostname [name] | HOSTNAME | Text, sanitized to a-z0-9-, 63 characters | vm | HN |
vm_prompt_cpu_model [model] | CPU MODEL | KVM64, Host, x86-64-v2-AES, x86-64-v3, x86-64-v4 | kvm64 | CPU_TYPE |
vm_prompt_cpu_cores [n] | CORE COUNT | Positive integer, at most the host's thread count | 2 | CORE_COUNT |
vm_prompt_ram [mib] | RAM | Positive integer in MiB | 2048 | RAM_SIZE |
vm_prompt_bridge [bridge] | BRIDGE | Name of an existing interface | vmbr0 | BRG |
vm_prompt_mac [mac] | MAC ADDRESS | XX:XX:XX:XX:XX:XX | $GEN_MAC | MAC |
vm_prompt_vlan [tag] | VLAN | 1 to 4094, blank for none | blank | VLAN |
vm_prompt_mtu [mtu] | MTU SIZE | 576 to 65520, blank for default | blank | MTU |
vm_prompt_keyboard [layout] | KEYBOARD LAYOUT | 25 Proxmox layout codes (de, en-us, fr-ch, ...) | Datacenter setting, else en-us | VM_KEYBOARD |
vm_prompt_verbose [yes/no] | VERBOSE MODE | Verbose, Silent | no | VERBOSE, STD |
vm_prompt_start_vm [yes/no] | START VIRTUAL MACHINE | Yes, No | yes | START_VM |
vm_confirm_advanced_settings <message> | ADVANCED SETTINGS COMPLETE | Yes, Do-Over | return code |
Five more prompts exist in the engine for scripts that need them. No released or testing script calls them today:
| Function | Dialog title | Choices | Default | Sets |
|---|---|---|---|---|
vm_prompt_bios [bios] | BIOS | OVMF (UEFI), SeaBIOS (Legacy) | ovmf | BIOS_TYPE, BIOS_UEFI |
vm_prompt_scsihw [controller] | SCSI CONTROLLER | virtio-scsi-pci, virtio-scsi-single | virtio-scsi-pci | SCSIHW |
vm_prompt_display [vga] | DISPLAY | serial0, std, none | serial0 | VGA_TYPE |
vm_prompt_nic_model [model] | NETWORK MODEL | virtio, e1000, rtl8139 | virtio | NIC_MODEL |
vm_prompt_balloon [yes/no] | MEMORY BALLOONING | Off, On (minimum is half of RAM_SIZE) | no | BALLOON |
Script-specific questions use vm_dialog directly. The Ubuntu release picker:
elif vm_dialog radiolist "UBUNTU VERSION" "Choose the Ubuntu release to install" --cancel-button Exit-Script 13 60 4 \
"26.04" "Ubuntu 26.04 LTS (Resolute)" ON \
"24.04" "Ubuntu 24.04 LTS (Noble)" OFF \
"22.04" "Ubuntu 22.04 LTS (Jammy)" OFF; then
var_version="$VM_DIALOG_RESULT"
else
exit_script
fivm_dialog <box> <title> <whiptail arguments...> returns 0 for OK and 1 for
Cancel, and puts the answer in VM_DIALOG_RESULT. Anything else ends the
script: Esc as "User exited script", a dialog that could not be drawn with an
error that names the terminal size.
On ARM hosts
vm_prompt_machine_typeandvm_apply_machine_typealways choosevirt, whatever was passed. There is no dialog.vm_prompt_cpu_modelleavesCPU_TYPEempty, so Proxmox VE uses the host CPU. All listed models are x86.
Unattended
With VM_UNATTENDED=1 no dialog is shown. vm_start_script calls
default_settings, then applies these overrides:
| Variable | Overrides | Notes |
|---|---|---|
VM_VMID | VMID | |
VM_HOSTNAME | HN | |
VM_CORES, VM_RAM | CORE_COUNT, RAM_SIZE | |
VM_DISK_SIZE | DISK_SIZE | Write it as 20G |
VM_DISK_CACHE | DISK_CACHE | The fragment, for example cache=writethrough, |
VM_BRIDGE, VM_MAC | BRG, MAC | |
VM_VLAN, VM_MTU | VLAN, MTU | Bare numbers |
VM_STORAGE | STORAGE | Skips the storage dialog |
VM_MACHINE | Machine type | q35 or i440fx |
VM_CPU | CPU_TYPE | host; any other value clears CPU_TYPE, which leaves the Proxmox default |
VM_START | START_VM | yes or no |
VM_CLOUD_INIT | USE_CLOUD_INIT | yes or no, read by vm_prompt_cloud_init |
VM_KEYBOARD, VM_TIMEZONE | Keyboard layout and time zone | Otherwise taken from the host |
VM_ISO_STORAGE | ISO storage | Needed when more than one storage holds ISOs |
Scripts add their own. Release pickers read VM_OS_VERSION.
VM_UNATTENDED=1 VM_OS_VERSION=24.04 VM_CORES=4 VM_RAM=4096 VM_DISK_SIZE=20G VM_STORAGE=local-lvm \
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu-vm.sh)"The container variables (var_cpu, mode=, defaults files) have no effect on
a Proxmox VE VM script.
5. The Cloud-Init question
VM_CLOUD_INIT="${VM_CLOUD_INIT:-yes}"
vm_prompt_cloud_init "ubuntu"
if [ "$USE_CLOUD_INIT" != "yes" ]; then
msg_warn "Without Cloud-Init this Ubuntu image gets no network configuration or credentials; configure them in the guest yourself."
fivm_prompt_cloud_init [default user] asks three things: whether to enable
Cloud-Init, the user name, and a password (twice; empty means one is
generated). It sets USE_CLOUD_INIT and the CLOUDINIT_* variables.
On the Advanced path it continues with network mode (DHCP or static IP and
gateway), DNS servers and SSH keys. A default-settings run gets DHCP, the DNS
servers 1.1.1.1 8.8.8.8 and no SSH key.
Two things a script can set first:
CLOUDINIT_REQUIRED=1drops the yes/no question.vm/almalinux-vm.shdoes this because its image has no console login without Cloud-Init.VM_CLOUD_INITis the answer for unattended runs. Without it the answer isno, oryeswhenCLOUDINIT_REQUIRED=1.
vm/debian-vm.sh asks this question before vm_start_script, because the
answer chooses the image (genericcloud or nocloud). vm_start_script then
asks the Advanced-only part when the user picks Advanced.
6. Storage and disk names
post_to_api_vm
vm_select_storage "$HN"
vm_define_disk_references 2post_to_api_vm sends the "installing" telemetry record; from here on a
failure is reported.
vm_select_storage lists storages with images content. One storage is taken
without asking, several open the Storage Pools dialog, a preset STORAGE
is used as it is. No storage, or a preset name that does not exist, ends the
script with exit code 119. It then sets the layout variables from the storage
type:
| Storage type | DISK_EXT | DISK_REF | DISK_IMPORT_FORMAT | THIN |
|---|---|---|---|---|
nfs, dir, cifs | .qcow2 | $VMID/ | qcow2 | empty |
btrfs | .raw | $VMID/ | raw | empty; also sets FORMAT=",efitype=4m" |
| anything else | empty | empty | raw | kept, default discard=on,ssd=1, |
vm_define_disk_references <count> builds the volume names Proxmox VE will
use. With 2 on local-lvm and VM 105:
| Variable | Value |
|---|---|
DISK0 | vm-105-disk-0 |
DISK0_REF | local-lvm:vm-105-disk-0 |
DISK1 | vm-105-disk-1 |
DISK1_REF | local-lvm:vm-105-disk-1 |
On dir storage DISK0_REF becomes local:105/vm-105-disk-0.qcow2.
Older scripts such as vm/debian-vm.sh still carry this case and the loop
inline, with DISK_IMPORT="-format raw" instead of DISK_IMPORT_FORMAT.
7. Downloading the image
UBUNTU_ARCH="$(vm_arch_resolve amd64 arm64)"
URL="https://cloud-images.ubuntu.com/releases/server/${UBUNTU_CODENAME}/release/ubuntu-${var_version}-server-cloudimg-${UBUNTU_ARCH}.img"
CACHE_FILE="$(vm_image_cache_path "$URL")"
vm_fetch_image "$URL" "$CACHE_FILE" --cache --min-bytes $((100 * 1024 * 1024)) || exit 115
FILE="$(basename "$CACHE_FILE")"
cp -f "$CACHE_FILE" "$FILE"vm_arch_resolve <amd64 value> <arm64 value>prints the value for the host, so the URL is never hardcoded to one architecture.vm_image_cache_pathreturns/var/lib/vz/template/cache/<file name>(VM_IMAGE_CACHE_DIRoverrides the directory).vm_fetch_imagedownloads into<target>.part, checks it, and only then renames it. With--cache, an existing file is verified and reused.- The script then works on a copy in
TEMP_DIR. The next step writes a hostname and a machine-id into the image; doing that to the cached file would hand them to every later VM.
vm_fetch_image options:
| Option | Effect |
|---|---|
--cache | Keep the file for the next run and verify it before reuse |
--min-bytes N | Fail when the download is smaller. Catches a mirror that serves an error page with status 200. |
--exact-bytes N | Fail unless the size matches |
--sha256 H, --sha1 H | Fail unless the checksum matches |
--checksum-url URL | Look the checksum up in a sums file, by the target's file name |
--advisory-sha1 H | Report a mismatch but continue |
--verify-xz | Run xz -t on the file |
--needs-bytes N | Check free space for the download plus the unpacked size before transferring |
Without a checksum option, the vendor's own list is used when the URL is on
cloud.debian.org, cloud-images.ubuntu.com, download.truenas.com,
downloads.openwrt.org, download.freebsd.org (VM images) or
mirror.turnkeylinux.org.
When a cached file no longer matches the upstream checksum, an Image Cache dialog offers Download or Keep cached. Unattended runs download the new one.
Finding the URL is the script's job. Three helpers cover the common cases:
# vm/zimaos-vm.sh: an asset of the latest GitHub release
vm_release_asset github IceWhaleTech/ZimaOS 'zimaos-x86_64-.*_installer\.iso$'
URL="$VM_RELEASE_URL"
vm_fetch_image "$URL" "$CACHE_FILE" --cache --min-bytes "$MIN_ISO_BYTES" --sha256 "$VM_RELEASE_SHA256" || exit 115
# vm/openwrt-vm.sh: the newest version named on a page
vm_latest_from_index "https://downloads.openwrt.org/.versions.json" '"stable_version": *"\K[0-9]+\.[0-9]+\.[0-9]+' || exit 115
var_version="$VM_INDEX_LATEST"
# vm/haos-vm.sh: unpack a compressed image; the result is in VM_IMAGE_FILE
vm_extract_image "$CACHE_FILE" || exit 115Installer ISOs
Scripts that boot an installer put the ISO on an ISO storage instead of the
image cache. From vm/archlinux-vm.sh:
vm_select_iso_storage "$FILENAME" "$HN"
CACHE_FILE="$ISO_PATH"
vm_fetch_image "$URL" "$CACHE_FILE" --cache --min-bytes $((100 * 1024 * 1024)) || exit 115vm_select_iso_storage <file.iso> [hostname] picks an active storage with
iso content (the disk storage if it qualifies, otherwise the only one, or a
dialog) and sets ISO_STORAGE, ISO_VOLUME and ISO_PATH. The VM then gets
-cdrom "$ISO_VOLUME".
8. Preparing and converting the image
vm_prepare_cloud_image "$FILE" "$HN" || true
if [[ "$USE_CLOUD_INIT" != "yes" ]]; then
vm_expand_image "$FILE" "$DISK_SIZE"
fivm_prepare_cloud_image <image> [hostname] installs libguestfs-tools on the
host if needed and then changes the image offline with virt-customize:
- sets the hostname;
- empties
/etc/machine-id, so two VMs from one image do not share a DHCP lease; - allows root and password login in
sshd_config; - sets the host's time zone and keyboard layout;
- enables a getty on
tty1andttyS0, adds root autologin when the image has nocloud-init, and addsconsole=tty1to the kernel command line; - installs and enables
qemu-guest-agent.
A step that fails is listed by name; the function still returns 0. It returns 1
only when virt-customize is not available, and then the image is imported
unmodified. That is why scripts write || true after it.
vm_expand_image <image> <size> grows the root partition and filesystem inside
the image with virt-resize. It returns 1 when it could not, which is why
debian-vm.sh and docker-vm.sh call it with || true. It is needed only without Cloud-Init: qm resize
grows the block device, and in a Cloud-Init image growpart then grows the
partition on first boot. Without Cloud-Init nothing would.
Script-specific changes use vm_customize, which returns the failure so the
script can react. From vm/unifi-os-server-vm.sh:
vm_customize "UniFi OS installer" "$FILE" \
--upload "unifi-os-server.bin:/opt/unifi-os-server.bin" \
--chmod 0755:/opt/unifi-os-server.bin \
--run-command "systemctl enable ssh" || exit 1
vm_firstboot_unit "$FILE" "unifi-os-firstboot" "$FIRSTBOOT_SCRIPT" \
--description "UniFi OS Server First Boot Installer" \
--after qemu-guest-agent.service \
--requires-path /opt/unifi-os-server.bin \
--cloud-init yes || exit 1vm_firstboot_unit uploads a script and a oneshot systemd unit that runs it
once on first boot. This is how a VM script installs something that cannot be
installed offline.
Format conversion
vm_import_disk passes --format "$DISK_IMPORT_FORMAT" and Proxmox VE converts
during the import, so ubuntu-vm.sh has no conversion step. Some scripts
convert first. From vm/fedora-vm.sh:
if [[ "$DISK_IMPORT_FORMAT" == "raw" ]]; then
msg_info "Converting image to raw format"
RAW_FILE=$(mktemp --suffix=.raw)
qemu-img convert -f qcow2 -O raw "$WORK_FILE" "$RAW_FILE"
rm -f "$WORK_FILE"
WORK_FILE="$RAW_FILE"
msg_ok "Converted image to raw format"
fi9. Creating the VM
vm_claim_vmid
msg_info "Creating a ${APP}"
qm create $VMID -agent 1${MACHINE} -tablet 0 -bios ovmf${CPU_TYPE} -cores $CORE_COUNT -memory $RAM_SIZE \
-name $HN -tags community-script -net0 virtio,bridge=$BRG,macaddr=$MAC$VLAN$MTU -onboot 1 -ostype l26 -scsihw virtio-scsi-pci
vm_mark_createdqm create is written out in every script; there is no wrapper. The engine
only supplies the fragments. With the defaults on an amd64 host this expands to:
qm create 105 -agent 1 -machine q35 -tablet 0 -bios ovmf -cores 2 -memory 2048 \
-name ubuntu -tags community-script -net0 virtio,bridge=vmbr0,macaddr=02:AB:CD:EF:01:23 -onboot 1 -ostype l26 -scsihw virtio-scsi-pci| Part | Comes from | Notes |
|---|---|---|
-machine | MACHINE | Empty for i440fx (the Proxmox default), -machine q35, or -machine virt on arm64 |
-bios ovmf | The script | vm/nextcloud-vm.sh and vm/owncloud-vm.sh use -bios seabios; vm/mikrotik-routeros.sh and vm/openwrt-vm.sh set none |
-cpu | CPU_TYPE | Empty means the Proxmox default. vm/almalinux-vm.sh sets -cpu x86-64-v3 for release 10 and -cpu x86-64-v2-AES for 9, because those releases do not boot on kvm64. |
-net0 | BRG, MAC, VLAN, MTU | The model is virtio in every script |
-agent 1 | The script | Pairs with the guest agent installed in step 8 |
-tags community-script | The script |
Two calls frame it:
vm_claim_vmidchecks the ID again. It was chosen before the download, and another run may have taken it since. An automatically chosen ID moves to the next free one, and the disk names from step 6 are rebuilt. An ID typed in the wizard ends the script with exit code 205.vm_mark_createdswitches the error handling: from here on a failure leaves the VM in place. It also sets the VM's keyboard layout.
10. EFI disk, disk import and qm set
vm_alloc_efi_disk "$DISK0"
vm_import_disk "$VMID" "$FILE" "$STORAGE" "$DISK_IMPORT_FORMAT"
qm set $VMID \
-efidisk0 ${DISK0_REF}${FORMAT} \
-scsi0 ${VM_IMPORTED_DISK},${DISK_CACHE}${THIN}size=${DISK_SIZE} \
-boot order=scsi0 \
-serial0 socket >/dev/nullEFI disk
vm_alloc_efi_disk [volume name] runs
pvesm alloc "$STORAGE" "$VMID" "$DISK0" <size>. The size is 4M on amd64
(OVMF) and 64M on arm64 (AAVMF). qm set -efidisk0 then writes the firmware
variables into that volume.
Scripts use one of three forms:
| Form | Example | Used by |
|---|---|---|
| Allocate, then attach | vm_alloc_efi_disk "$DISK0" and -efidisk0 ${DISK0_REF}${FORMAT} | ubuntu-vm.sh, debian-vm.sh, fedora-vm.sh |
| Let Proxmox VE allocate | --efidisk0 "${STORAGE}:0,efitype=4m" | haos-vm.sh, docker-vm.sh, unifi-os-server-vm.sh |
| Allocate without Secure Boot keys | -efidisk0 ${STORAGE}:1,efitype=4m,pre-enrolled-keys=0 | cachyos-vm.sh, zimaos-vm.sh, umbrel-os-vm.sh, truenas-vm.sh |
SeaBIOS VMs have no EFI disk.
Importing the disk
vm_import_disk <vmid> <image> <storage> [format] flushes the image to disk,
runs qm disk import (or qm importdisk where that subcommand is missing) and
stores the volume Proxmox VE created in VM_IMPORTED_DISK. The script attaches
that volume, not a guessed name.
Older scripts call qm importdisk themselves and attach ${DISK1_REF}:
# vm/debian-vm.sh
qm importdisk $VMID ${FILE} $STORAGE ${DISK_IMPORT:-} 1>&/dev/nullInstaller scripts import nothing. They create an empty disk in qm create.
From vm/zimaos-vm.sh:
-scsi0 ${STORAGE}:${DISK_SIZE%G},${DISK_CACHE}${THIN%,} \
-cdrom "$ISO_VOLUME" -boot order='scsi0;ide2' -vga std -serial0 socketWhat qm set attaches
| Option | Value | Meaning |
|---|---|---|
-efidisk0 | local-lvm:vm-105-disk-0 plus FORMAT | EFI variables |
-scsi0 | <imported volume>,discard=on,ssd=1,size=7G | Root disk with DISK_CACHE and THIN |
-boot | order=scsi0 | Installer scripts use order='scsi0;ide2' |
-serial0 | socket | Serial console, for qm terminal and xterm.js |
No Cloud-Init drive is attached here. vm_provision adds it in step 12, and
attaching it twice fails.
TPM
The engine has no TPM helper on Proxmox VE. almalinux-vm.sh, alpine-vm.sh
and fedora-vm.sh add a TPM 2.0 state disk themselves, as a third volume. From
vm/fedora-vm.sh:
vm_define_disk_references 3
# ...
vm_alloc_efi_disk "$DISK0"
pvesm alloc "$STORAGE" "$VMID" "$DISK2" 4M 1>&/dev/null
qm importdisk "$VMID" "$WORK_FILE" "$STORAGE" -format "$DISK_IMPORT_FORMAT" 1>&/dev/null
qm set "$VMID" \
-efidisk0 "${DISK0_REF}${FORMAT:-}" \
-scsi0 "${DISK1_REF}",${DISK_CACHE}${THIN}size="${DISK_SIZE}" \
-tpmstate0 "${DISK2_REF}",version=v2.0 \
-boot order=scsi0 \
-serial0 socket >/dev/null11. Description panel and disk resize
set_description
vm_resize_diskset_description writes the HTML panel shown under Notes in the Proxmox VE
web interface: the Community-Scripts logo, APP as the title, a donation
badge, a link to https://community-scripts.org/scripts/<slug>, and links to
GitHub, Discussions and Issues. The slug is SCRIPT_SLUG, else NSAPP, else
APP, in lower case without spaces.
vm_resize_disk [disk] [size] runs qm resize "$VMID" scsi0 "$DISK_SIZE".
The imported image is as large as the vendor made it; this grows the block
device to what the user asked for. qm refuses to shrink, so a failure is only
a warning.
12. Cloud-Init provisioning
vm_provision "$VMID"
if [[ "$USE_CLOUD_INIT" == "yes" && -n "${CLOUDINIT_SSH_KEYS:-}" ]]; then
$STD qm set "$VMID" --sshkeys "$CLOUDINIT_SSH_KEYS"
fi
if [ "$USE_CLOUD_INIT" = "yes" ]; then
if [[ "${CLOUDINIT_NETWORK_MODE:-dhcp}" == "static" ]]; then
setup_cloud_init_network_no_rename \
"$VMID" \
"$MAC" \
"$CLOUDINIT_IP" \
"$CLOUDINIT_GW" \
"${CLOUDINIT_DNS:-${CLOUDINIT_DNS_SERVERS:-1.1.1.1 8.8.8.8}}" \
"${CLOUDINIT_SEARCH_DOMAIN:-local}"
fi
fivm_provision <vmid> does nothing when USE_CLOUD_INIT is not yes.
Otherwise it loads vm/cloud-init.func and calls setup_cloud_init with the
answers from step 5. That attaches the Cloud-Init drive and sets user,
password, SSH keys, IP configuration, DNS and search domain. The credentials
are written to /tmp/<hostname>-<vmid>-cloud-init-credentials.txt.
If the helpers cannot be loaded, vm_provision warns and returns 1. The VM
exists by then, so scripts either ignore that (vm_provision "$VMID" || true
in fedora-vm.sh) or report it (docker-vm.sh).
The static-IP block is specific to ubuntu-vm.sh and debian-vm.sh. It
replaces Proxmox VE's generated network configuration with a snippet that
matches the NIC by MAC address. See
the network snippet.
13. Start, summary and finish
vm_start_vm "$APP"
vm_wait_for_ip 120 || true
if [ "$USE_CLOUD_INIT" = "yes" ] && declare -f display_cloud_init_info >/dev/null 2>&1; then
display_cloud_init_info "$VMID" "$HN"
fi
vm_print_summary \
"Ubuntu Release=${var_version} (${UBUNTU_CODENAME})" \
"Cloud-Init=${USE_CLOUD_INIT}"
vm_finishvm_start_vm [label]runsqm startwhenSTART_VMisyes.vm_wait_for_ip [seconds]asks the guest agent for the address of the NIC with the VM's MAC and stores it inVM_IP. It returns non-zero when the VM was not started or no address appeared, so it is followed by|| true.display_cloud_init_infoprints user, password and the credentials file.vm_print_summary ["Label=value"...]prints VM ID, hostname, resources, state and IP address, then the extra rows. Empty values are skipped.vm_next_steps <step>...prints a numbered list.vm_finish [message]sends the "done" telemetry status and prints the closing line.
Older scripts start the VM with $STD qm start $VMID and call
post_update_to_api "done" "none" themselves.
What happens when a step fails
| Failure | Result |
|---|---|
Before qm create | Nothing to clean up except TEMP_DIR. The cached image stays. |
After qm create, script never calls vm_mark_created | A failing command reaches error_handler, which stops and destroys the VM. This is the case in debian-vm.sh, almalinux-vm.sh, alpine-vm.sh, fedora-vm.sh and haos-vm.sh, among others. An explicit exit, such as vm_import_disk ... || exit 226, does not go through error_handler and leaves the VM. |
After vm_mark_created | A warning names the failed line and command, the VM is left in place, and the script exits with the failed command's status. |
| A helper that only warns | vm_prepare_cloud_image and vm_resize_disk report and return 0. vm_expand_image and vm_wait_for_ip warn and return 1, so a script that wants to carry on writes || true after them. |
Run a script with VERBOSE=yes, or pick Verbose in the wizard, to see command
output instead of the spinner. The log of a run is /tmp/cs-vm-<pid>.log.
Scripts use these exit codes for their own checks:
| Code | Used for |
|---|---|
| 115 | vm_fetch_image, vm_extract_image or a version lookup failed |
| 226 | vm_import_disk failed, or the VM did not reach the expected state afterwards |
The codes the engine itself uses are listed under exit codes.
Helper functions a VM script may call
Signatures as defined in pve/vm-core.func and vm/common.func. The wizard
prompts are in the table above.
Setup and checks
| Function | Purpose |
|---|---|
load_functions | Colours, icons, STD, telemetry functions, Bash check |
header_info [name] | Clear the screen and draw the banner for APP |
vm_preflight | Root, architecture, Proxmox VE version, KVM and SSH checks |
vm_require_arch <amd64/arm64> | Stop with exit code 106 on the other architecture |
vm_arch | Print amd64 or arm64 |
vm_arch_resolve <amd64 value> <arm64 value> | Print the value for the host |
vm_require_tools <command>... | Install missing host tools or return 1 |
get_valid_nextid | Print the next VM ID that is free in the cluster and in LVM |
vm_start_script [message] [height] [width] | Default/Advanced fork, or unattended |
vm_dialog <box> <title> <args>... | One whiptail dialog, result in VM_DIALOG_RESULT |
vm_confirm_new_vm <title> <message> [height] [width] | Yes/no dialog, always yes when unattended |
vm_echo_default_settings | Print the settings summary |
exit_script [reason] | Print the reason and exit |
Machine settings
| Function | Purpose |
|---|---|
vm_apply_machine_type [i440fx/q35] | Set MACHINE_TYPE, MACHINE, FORMAT; always virt on arm64 |
vm_machine_type_label <type> | Print i440fx, Q35 (Modern) or virt (ARM64) |
vm_cpu_model_label | Print the model in CPU_TYPE, else KVM64 (amd64) or Host (arm64) |
vm_fit_cores | Lower CORE_COUNT to the host's thread count |
vm_keyboard_default | Print VM_KEYBOARD, else the datacenter keyboard setting |
vm_timezone_default | Print VM_TIMEZONE, else the host's time zone |
sanitize_vm_hostname <name> | Lower case, a-z0-9- only, 63 characters |
Images
| Function | Purpose |
|---|---|
vm_image_cache_path <url> | Print the cache path for a URL |
vm_fetch_image <url> <target> [options] | Download and verify |
vm_image_checksum <url> | Print <tool> <hash> from the vendor's list |
vm_release_asset <github/gitlab/codeberg> <owner/repo> <asset regex> [tag] | Set VM_RELEASE_TAG, VM_RELEASE_VERSION, VM_RELEASE_ASSET, VM_RELEASE_URL, VM_RELEASE_SHA256 |
vm_latest_from_index <index url> <pcre> [--sort-by <pcre>] [--probe <url>]... | Set VM_INDEX_LATEST and, with --probe, VM_INDEX_URL |
vm_extract_image <archive> [target] | Unpack .xz, .gz, .bz2, .zst; set VM_IMAGE_FILE |
vm_ensure_virt_customize | Install libguestfs-tools if virt-customize is missing |
vm_prepare_cloud_image <image> [hostname] | The shared image preparation |
vm_enable_consoles <image> | Console part of the preparation only |
vm_install_guest_agent <image> | Guest-agent part only |
vm_expand_image <image> <size> | Grow the root filesystem offline |
vm_customize <label> <image> <virt-customize args>... | One virt-customize step that returns its failure |
vm_firstboot_unit <image> <name> <script> [--description <text>] [--after <unit>]... [--requires-path <path>] [--cloud-init yes/no] | Stage a run-once unit; set VM_FIRSTBOOT_MARKER |
Storage and disks
| Function | Purpose |
|---|---|
vm_select_storage [hostname] | Set STORAGE, STORAGE_TYPE and the layout variables |
vm_apply_storage_layout <storage type> | Layout variables only |
vm_select_iso_storage <file.iso> [hostname] | Set ISO_STORAGE, ISO_VOLUME, ISO_PATH |
vm_define_disk_references [count] | Set DISK0, DISK0_REF, DISK1, ... (default 2) |
vm_claim_vmid | Re-check the VM ID right before qm create |
vm_alloc_efi_disk [volume name] | Allocate the EFI variables volume |
vm_import_disk <vmid> <image> <storage> [format] | Import; set VM_IMPORTED_DISK |
vm_boot_order <disk> [image/installer] | Print <disk> or <disk>;ide2 |
vm_resize_disk [disk] [size] | qm resize; defaults scsi0 and DISK_SIZE |
After creation
| Function | Purpose |
|---|---|
vm_mark_created | From here on a failure keeps the VM |
set_description | Write the description panel |
vm_provision <vmid> | Apply Cloud-Init when enabled |
vm_start_vm [label] | Start when START_VM is yes |
vm_wait_guest_agent <vmid> [seconds] | Wait for the guest agent, default 120 |
vm_guest_exec <vmid> <seconds> [--] <command> [args]... | Run a command in the guest; returns its exit code, 124 on timeout |
vm_wait_for_ip [seconds] | Set VM_IP, default 120 |
vm_wait_http <url> [seconds] [--insecure] | Wait until a web UI answers, default 120 |
vm_print_summary ["Label=value"]... | Closing summary |
vm_next_steps <step>... | Numbered follow-up list |
vm_finish [message] | Report success |
The same VM on Incus
vm/ubuntu-vm.sh in the Incus repository
is not a port. It loads incus/vm-core.func, sets var_image, and after the
wizard makes one call:
var_image="${var_image:-images:ubuntu/26.04}"
# ...
vm_start_script
incus_vm_create "$var_image" "$HN" "$DISK_SIZE"incus_vm_create <image> <name> <disk-size> replaces steps 6 to 13. It
resolves the image, runs incus init --vm, applies every setting, writes the
Cloud-Init configuration into the instance config, starts the VM and waits for
the agent and an address.
There is no image download, no virt-customize, no EFI disk, no import and no
machine type. A script that needs a vendor image passes a file instead of an
image alias. From the Incus vm/haos-vm.sh:
vm_fetch_image "$URL" "$CACHE_FILE" --cache --verify-xz --min-bytes $((5 * 1024 * 1024)) || exit 115
xz -dc "$CACHE_FILE" >"$IMAGE_FILE"
incus_vm_create "$IMAGE_FILE" "$HN" "$DISK_SIZE"The Incus wizard asks different questions (Secure Boot, virtual TPM, GPU, USB and PCI passthrough, snapshots, profiles). They are listed on the engine page.
Before you submit a VM script
- Start from a current script of the same kind:
ubuntu-vm.shfor a cloud image,zimaos-vm.shfor an installer ISO,unifi-os-server-vm.shfor an application installed on first boot. - Use
vm_arch_resolvein URLs, or state the architecture withvm_require_arch. - Download with
vm_fetch_image --cacheand a--min-bytesthat a real image clears easily. Pass a checksum when the upstream publishes one. - Work on a copy of the cached file.
- Call
vm_claim_vmidbeforeqm createandvm_mark_createdafter it. - Test on file storage (
dir) and on block storage (LVM-thin or ZFS). The volume names and the import format differ. - Test the default path, the Advanced path and
VM_UNATTENDED=1.