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. Setup is unattended (autounattend.xml on a second CD-ROM), and the VM comes up with a static IP and OpenSSH Server that accepts your SSH key.
Result
| Item | Value |
|---|---|
| OS | Windows Server 2025 Standard Evaluation, Desktop Experience (English, x64; verified with build 26100), 180-day evaluation |
| VM | SeaBIOS, cpu=host, 2 vCPU, 4 GB RAM, 64 GB SATA disk (defaults; see Resources), Intel E1000E NIC (no VirtIO drivers needed) |
| Network | Static IPv4 set at first logon |
| Login | Local administrator $VM_USER over SSH with your key; default SSH shell is PowerShell |
| 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-url). - About 5.7 GB free on the ISO storage, 64 GB on the disk storage.
- A free IP address and VMID. The VM needs Internet access (OpenSSH Server is installed from Windows Update).
Resources
| CPU | RAM | Disk | |
|---|---|---|---|
| Minimum (Microsoft: Windows Server hardware requirements, Windows Server 2025 tab) | 1.4 GHz 64-bit with SSE4.2 and POPCNT | 2 GB (Desktop Experience) | 32 GB |
| Recommended (same page) | 4 GB (Desktop Experience) | ||
| Defaults in this guide (verified) | 2 vCPU | 4 GiB | 64 GiB |
The defaults meet Microsoft’s recommended memory for Desktop Experience; Microsoft calls 32 GB of disk an absolute minimum. With the defaults, Step 5 to SSH login took about 11 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. VMNAME becomes the Windows computer name (max 15 characters). Do not use &, < or > in VM_PASS.
PVE_HOST=192.168.1.13 # Proxmox VE host
PVE_NODE=pve # node name
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=64
VMID=809
VMNAME=ws2025
IP=192.168.1.89
PREFIX=24
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
The Microsoft Evaluation Center link is a redirect; curl resolves it to the current ISO URL, and Proxmox downloads it. Skipped if the file already exists. Expected output: the ISO name and OK (about 5.7 GB).
URL=$(curl -sIL -o /dev/null -w '%{url_effective}' 'https://go.microsoft.com/fwlink/?linkid=2293312&clcid=0x409&culture=en-us&country=us')
ISO=$(basename "${URL%%\?*}"); 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=$URL" | jq -r .data)"
Step 4. Create the autounattend ISO and upload it
Windows Setup searches the root of every removable drive for autounattend.xml. Image index 2 of the evaluation ISO is “Standard (Desktop Experience)” (1 = Standard Core, 3 = Datacenter Core, 4 = Datacenter Desktop Experience). Evaluation media needs no product key. AdministratorPassword also sets the built-in Administrator password, which Server OOBE would otherwise ask for. First-logon commands set the static IP, install OpenSSH Server, register your public key for administrators, make PowerShell the default SSH shell, and start sshd last. Expected output: OK.
mkdir -p /tmp/$VMNAME
cat > /tmp/$VMNAME/autounattend.xml <<EOF
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage><UILanguage>en-US</UILanguage></SetupUILanguage>
<InputLocale>en-US</InputLocale><SystemLocale>en-US</SystemLocale><UILanguage>en-US</UILanguage><UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<DiskConfiguration><Disk wcm:action="add"><DiskID>0</DiskID><WillWipeDisk>true</WillWipeDisk>
<CreatePartitions><CreatePartition wcm:action="add"><Order>1</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition></CreatePartitions>
<ModifyPartitions><ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Format>NTFS</Format><Letter>C</Letter><Active>true</Active></ModifyPartition></ModifyPartitions>
</Disk></DiskConfiguration>
<ImageInstall><OSImage><InstallFrom><MetaData wcm:action="add"><Key>/IMAGE/INDEX</Key><Value>2</Value></MetaData></InstallFrom><InstallTo><DiskID>0</DiskID><PartitionID>1</PartitionID></InstallTo></OSImage></ImageInstall>
<UserData><AcceptEula>true</AcceptEula><FullName>$VM_USER</FullName></UserData>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>$VMNAME</ComputerName><TimeZone>UTC</TimeZone>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<InputLocale>en-US</InputLocale><SystemLocale>en-US</SystemLocale><UILanguage>en-US</UILanguage><UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE><HideEULAPage>true</HideEULAPage><HideLocalAccountScreen>true</HideLocalAccountScreen><HideOEMRegistrationScreen>true</HideOEMRegistrationScreen><HideOnlineAccountScreens>true</HideOnlineAccountScreens><HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE><ProtectYourPC>3</ProtectYourPC></OOBE>
<UserAccounts><AdministratorPassword><Value>$VM_PASS</Value><PlainText>true</PlainText></AdministratorPassword><LocalAccounts><LocalAccount wcm:action="add"><Name>$VM_USER</Name><Group>Administrators</Group><Password><Value>$VM_PASS</Value><PlainText>true</PlainText></Password></LocalAccount></LocalAccounts></UserAccounts>
<AutoLogon><Enabled>true</Enabled><Username>$VM_USER</Username><Password><Value>$VM_PASS</Value><PlainText>true</PlainText></Password><LogonCount>1</LogonCount></AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add"><Order>1</Order><CommandLine>powershell -NoProfile -Command "Get-NetAdapter | New-NetIPAddress -IPAddress $IP -PrefixLength $PREFIX -DefaultGateway $GW; Get-NetAdapter | Set-DnsClientServerAddress -ServerAddresses $DNS; Start-Sleep 20"</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>2</Order><CommandLine>powershell -NoProfile -Command "Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0"</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>3</Order><CommandLine>powershell -NoProfile -Command "New-Item -ItemType Directory -Force C:\ProgramData\ssh; Set-Content -Path C:\ProgramData\ssh\administrators_authorized_keys -Value '$SSH_PUBKEY'"</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>4</Order><CommandLine>icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant *S-1-5-32-544:F /grant *S-1-5-18:F</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>5</Order><CommandLine>reg add HKLM\SOFTWARE\OpenSSH /v DefaultShell /t REG_SZ /d C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe /f</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>6</Order><CommandLine>netsh advfirewall firewall add rule name=sshd dir=in action=allow protocol=TCP localport=22</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>7</Order><CommandLine>net accounts /maxpwage:unlimited</CommandLine></SynchronousCommand>
<SynchronousCommand wcm:action="add"><Order>8</Order><CommandLine>powershell -NoProfile -Command "Set-Service sshd -StartupType Automatic; Start-Service sshd"</CommandLine></SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
EOF
genisoimage -quiet -J -r -V UNATTEND -o /tmp/$VMNAME/$VMNAME-unattend.iso /tmp/$VMNAME/autounattend.xml
wait_task "$(pve POST /nodes/$PVE_NODE/storage/$ISO_STORE/upload -F content=iso -F filename=@/tmp/$VMNAME/$VMNAME-unattend.iso | jq -r .data)"
Step 5. Create and start the VM
SATA and E1000E use drivers that ship with Windows, so no VirtIO driver ISO is needed. With SeaBIOS and an empty disk first in the boot order, the VM boots the ISO without the “Press any key to boot from CD” prompt, and boots the installed disk after Setup reboots. Expected output: OK.
wait_task "$(pve POST /nodes/$PVE_NODE/qemu -d vmid=$VMID -d name=$VMNAME -d ostype=win11 -d cpu=host -d cores=$CORES -d memory=$MEMORY \
--data-urlencode "sata0=$DISK_STORE:$DISK_GB" --data-urlencode "net0=e1000e,bridge=$BRIDGE" \
--data-urlencode "ide2=$ISO_STORE:iso/$ISO,media=cdrom" --data-urlencode "ide3=$ISO_STORE:iso/$VMNAME-unattend.iso,media=cdrom" \
--data-urlencode "boot=order=sata0;ide2" -d start=1 | jq -r .data)"
Step 6. Wait for SSH and log in
Setup, two reboots and the first-logon commands run without interaction. sshd is started by the last command, so a successful key login means everything is done. Verified time from Step 5 to login: about 11 minutes.
SSHO="-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
until ssh $SSHO $VM_USER@$IP exit 2>/dev/null; do sleep 30; done
ssh $SSHO $VM_USER@$IP '(Get-CimInstance Win32_OperatingSystem).Caption; Get-NetIPAddress -AddressFamily IPv4 -PrefixOrigin Manual | Select-Object IPAddress,PrefixLength'
Step 7. Clean up (optional)
Detach both CD-ROMs and delete the answer-file 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-unattend.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>).- No SSH after 45 minutes: open the VM console in the Proxmox web UI. If Setup shows a dialog, the answer file was not applied (check that the second CD-ROM is attached and the XML is well-formed with
xmllint --noout). If the desktop is up but SSH is closed,Add-WindowsCapabilityprobably could not reach Windows Update; check the IP settings. - 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".