This guide is written for AI agents. Every step is a copy-paste shell block that runs on a Linux workstation and talks to the Proxmox VE REST API with curl. Nothing has to be installed or run on the Proxmox host, and no console interaction is needed. The install is unattended (Kickstart on a second CD-ROM labelled OEMDRV), and the VM comes up with a static IP and key-based SSH.
Result
| Item | Value |
|---|---|
| OS | Rocky Linux 9 (latest minor; verified with 9.8 minimal ISO) |
| VM | SeaBIOS, cpu=host, 2 vCPU, 4 GB RAM, 32 GB VirtIO SCSI disk (defaults; see Resources), VirtIO NIC |
| Network | Static IPv4 set at install time |
| Login | $VM_USER with your SSH public key, passwordless sudo |
| Agent work | 6 command blocks and one wait loop |
Requirements
- Workstation:
bash,curl,jq,genisoimage(ormkisofs),ssh, and an SSH key pair (~/.ssh/id_ed25519.pub). - Proxmox VE 8.x/9.x reachable on port 8006. The examples log in as
root@pam. Another user needsVM.Allocate,VM.Config.*,VM.PowerMgmt,Datastore.AllocateSpace,Datastore.AllocateTemplateandSys.Modify(required bydownload-urlandsendkey). - A storage that accepts ISO images (
local) and one for VM disks (local-lvm). - A free IP address and VMID.
Resources
| CPU | RAM | Disk | |
|---|---|---|---|
| Minimum (Red Hat: RHEL 9 system requirements) | x86_64 (x86-64-v2) | 1.5 GiB (local media install) | 10 GiB |
| Defaults in this guide (verified) | 2 vCPU | 4 GiB | 32 GiB |
Rocky Linux publishes no separate hardware requirements for release 9; it is built to be compatible with RHEL 9, so Red Hat’s figures apply. Red Hat gives no recommended values beyond the minimum. With the defaults, Step 5 to SSH login took about 8 minutes. To change them after installation, run pve PUT /nodes/$PVE_NODE/qemu/$VMID/config -d cores=4 -d memory=8192; the new values take effect after the VM is stopped and started again.
Step 1. Set variables
Edit the values, then run the block. All later blocks use these variables in the same shell.
PVE_HOST=192.168.1.13 # Proxmox VE host
PVE_NODE=pve # node name (pvesh get /nodes, or the GUI tree)
PVE_USER=root@pam
PVE_PASS='your-root-password'
ISO_STORE=local # storage with "ISO image" content
DISK_STORE=local-lvm # storage for VM disks
BRIDGE=vmbr0
CORES=2 # vCPUs (see "Resources")
MEMORY=4096 # MiB
DISK_GB=32
VMID=804
VMNAME=rocky9
IP=192.168.1.84
NETMASK=255.255.255.0
GW=192.168.1.1
DNS=8.8.8.8
VM_USER=agent
VM_PASS='ChangeMe-2026'
SSH_PUBKEY=$(cat ~/.ssh/id_ed25519.pub)
Step 2. Log in to the API
Creates a ticket (valid 2 hours) and two helpers: pve (API call) and wait_task (waits for a Proxmox task and prints its exit status, OK on success).
PVE=https://$PVE_HOST:8006/api2/json
read -r TICKET CSRF < <(curl -sk -d "username=$PVE_USER" --data-urlencode "password=$PVE_PASS" $PVE/access/ticket | jq -r '.data|"\(.ticket) \(.CSRFPreventionToken)"')
pve(){ local m=$1 p=$2; shift 2; curl -sSk -X "$m" -b "PVEAuthCookie=$TICKET" -H "CSRFPreventionToken: $CSRF" "$PVE$p" "$@"; }
wait_task(){ local u s; u=$(jq -rn --arg u "$1" '$u|@uri'); while sleep 5; do s=$(pve GET /nodes/$PVE_NODE/tasks/$u/status | jq -r '.data|"\(.status) \(.exitstatus)"'); [ "${s%% *}" = stopped ] && { echo "${s#* }"; return; }; done; }
pve GET /version | jq -c .data
Step 3. Download the ISO to Proxmox
Proxmox downloads the latest minimal ISO itself and verifies its SHA-256. The block is skipped if the file already exists. Expected output: OK (a few minutes for about 2.6 GB).
BASE=https://download.rockylinux.org/pub/rocky/9/isos/x86_64
read -r ISO SHA < <(curl -s $BASE/CHECKSUM | sed -nE 's/^SHA256 \((Rocky-9\.[0-9]+-x86_64-minimal\.iso)\) = ([0-9a-f]+)$/\1 \2/p' | head -1)
echo "$ISO"
pve GET "/nodes/$PVE_NODE/storage/$ISO_STORE/content?content=iso" | grep -q "iso/$ISO\"" && echo exists || \
wait_task "$(pve POST /nodes/$PVE_NODE/storage/$ISO_STORE/download-url -d content=iso -d filename=$ISO --data-urlencode url=$BASE/$ISO -d checksum=$SHA -d checksum-algorithm=sha256 | jq -r .data)"
Step 4. Create the Kickstart ISO and upload it
Anaconda automatically loads ks.cfg from any volume labelled OEMDRV, so no boot parameter has to be typed. Expected output: OK.
mkdir -p /tmp/$VMNAME
cat > /tmp/$VMNAME/ks.cfg <<EOF
text
lang en_US.UTF-8
keyboard us
timezone Etc/UTC --utc
network --bootproto=static --device=link --ip=$IP --netmask=$NETMASK --gateway=$GW --nameserver=$DNS --hostname=$VMNAME --activate
rootpw --lock
user --name=$VM_USER --password=$VM_PASS --plaintext --groups=wheel
sshkey --username=$VM_USER "$SSH_PUBKEY"
firewall --enabled --service=ssh
ignoredisk --only-use=sda
zerombr
clearpart --all --initlabel
autopart --type=lvm
bootloader --boot-drive=sda
reboot
%packages
@^minimal-environment
%end
%post
echo "$VM_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/$VM_USER
%end
EOF
genisoimage -quiet -V OEMDRV -o /tmp/$VMNAME/$VMNAME-ks.iso /tmp/$VMNAME/ks.cfg
wait_task "$(pve POST /nodes/$PVE_NODE/storage/$ISO_STORE/upload -F content=iso -F filename=@/tmp/$VMNAME/$VMNAME-ks.iso | jq -r .data)"
Step 5. Create and start the VM
cpu=host is required: the default kvm64 CPU type lacks x86-64-v2 and the Rocky 9 installer kernel panics. The boot order puts the empty disk first, so the VM boots the ISO now and the installed disk after the install reboots. Expected output: OK.
wait_task "$(pve POST /nodes/$PVE_NODE/qemu -d vmid=$VMID -d name=$VMNAME -d ostype=l26 -d cpu=host -d cores=$CORES -d memory=$MEMORY \
-d scsihw=virtio-scsi-single --data-urlencode "scsi0=$DISK_STORE:$DISK_GB" --data-urlencode "net0=virtio,bridge=$BRIDGE" \
--data-urlencode "ide2=$ISO_STORE:iso/$ISO,media=cdrom" --data-urlencode "ide3=$ISO_STORE:iso/$VMNAME-ks.iso,media=cdrom" \
--data-urlencode "boot=order=scsi0;ide2" -d start=1 | jq -r .data)"
sleep 10; for k in up ret; do pve PUT /nodes/$PVE_NODE/qemu/$VMID/sendkey -d key=$k >/dev/null; done
The last line selects “Install Rocky Linux” in the boot menu through the sendkey API. The default entry, “Test this media & install”, first reads the whole ISO (several minutes on busy storage). If the keys arrive too early or are not permitted, nothing breaks: the menu times out after 60 seconds, runs the media check and installs anyway.
Step 6. Wait for SSH and log in
The install runs, the VM reboots into the installed system, and the loop ends at the first successful key login. Verified time from Step 5 to login: about 8 minutes.
SSHO="-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
until ssh $SSHO $VM_USER@$IP true 2>/dev/null; do sleep 20; done
ssh $SSHO $VM_USER@$IP 'cat /etc/rocky-release; ip -4 -br addr; sudo -n true && echo sudo-ok'
Step 7. Clean up (optional)
Detach both CD-ROMs and delete the Kickstart ISO from storage.
pve PUT /nodes/$PVE_NODE/qemu/$VMID/config -d delete=ide3 --data-urlencode "ide2=none,media=cdrom"
pve DELETE /nodes/$PVE_NODE/storage/$ISO_STORE/content/$ISO_STORE:iso/$VMNAME-ks.iso
Troubleshooting
wait_taskprints an error instead ofOK: list the latest failed tasks and their status text withpve GET "/nodes/$PVE_NODE/tasks?errors=1" | jq -r '.data[:3][] | "\(.type) \(.id) \(.status)"'.Permission check failedon create: a non-root user must create the VM in a pool it has rights on (add-d pool=<pool>).- SSH never opens after 20 minutes: open the VM console in the Proxmox web UI. A Kickstart syntax error is shown on screen.
- To start over:
pve POST /nodes/$PVE_NODE/qemu/$VMID/status/stop, thenpve DELETE "/nodes/$PVE_NODE/qemu/$VMID?purge=1&destroy-unreferenced-disks=1".