Create a Windows 11 Pro VM on Proxmox VE (CLI only: ISO download → unattended install → SSH)

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

ItemValue
OSWindows 11 Pro (English, x64; verified with build 26300), not activated
VMSeaBIOS (TPM / Secure Boot checks bypassed by the answer file), cpu=host, 4 vCPU, 8 GB RAM, 64 GB SATA disk (defaults; see Resources), Intel E1000E NIC (no VirtIO drivers needed)
NetworkStatic IPv4 set at first logon
LoginLocal administrator $VM_USER over SSH with your key; default SSH shell is PowerShell
Agent work6 command blocks and one wait loop

Requirements

  • Workstation: bash, curl, jq, genisoimage (or mkisofs), 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 needs VM.Allocate, VM.Config.*, VM.PowerMgmt, Datastore.AllocateSpace, Datastore.AllocateTemplate and Sys.Modify (required by download-url).
  • About 9 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

CPURAMDisk
Minimum (Microsoft: Windows 11 specifications)1 GHz, 2 or more cores4 GB64 GB
Defaults in this guide (verified)4 vCPU8 GiB64 GiB

Microsoft also requires UEFI with Secure Boot and TPM 2.0; this guide bypasses those two checks in the answer file. Microsoft gives no recommended values beyond the minimum. Measured time from Step 5 to SSH login: about 48 minutes at the minimum (2 vCPU, 4 GiB; other VMs were installing at the same time) and about 40 minutes with the defaults (4 vCPU, 8 GiB; no other load). Extra resources help only a little, because most of the time goes to downloading updates and OpenSSH Server from Windows Update after Setup. 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=4                       # vCPUs (see "Resources")
MEMORY=8192                   # MiB
DISK_GB=64
VMID=802
VMNAME=win11
IP=192.168.1.82
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

Microsoft only hands out a time-limited (24 h) download link. The block requests it the same way the download page does (including the page’s anti-bot fingerprint call), then lets Proxmox download the ISO. Skipped if the file already exists. Expected output: the ISO name and OK (about 8.6 GB).

UA='Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0'
REF=https://www.microsoft.com/en-us/software-download/windows11
MSAPI=https://www.microsoft.com/software-download-connector/api
SID=$(cat /proc/sys/kernel/random/uuid)
ED=$(curl -s -A "$UA" $REF | grep -oE '<option value="[0-9]+">Windows' | head -1 | grep -oE '[0-9]+')
JS=$(curl -s -A "$UA" "https://ov-df.microsoft.com/mdt.js?instanceId=560dc9f3-1aa5-4a2f-b63c-9e18f8d0e175&PageId=si&session_id=$SID")
curl -s -o /dev/null -A "$UA" "$(echo "$JS" | grep -oE 'url:"[^"]+' | head -1 | cut -c6-)&mdt=$(date +%s%3N)&rticks=$(echo "$JS" | grep -oE 'rticks="\+[0-9]+' | grep -oE '[0-9]+')"
SKU=$(curl -s -A "$UA" "$MSAPI/getskuinformationbyproductedition?profile=606624d44113&ProductEditionId=$ED&SKU=undefined&friendlyFileName=undefined&Locale=en-US&sessionID=$SID" | jq -r '.Skus[] | select(.Language=="English") | .Id' | head -1)
URL=$(curl -s -A "$UA" -e $REF "$MSAPI/GetProductDownloadLinksBySku?profile=606624d44113&productEditionId=undefined&SKU=$SKU&friendlyFileName=undefined&Locale=en-US&sessionID=$SID" | jq -r '.ProductDownloadOptions[] | select(.Uri|test("x64")) | .Uri' | head -1)
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)"

If URL is empty, Microsoft rejected the request (rate limit or blocked IP range). Wait and retry, or download the ISO in a browser and upload it with the same upload call used in Step 4.

Step 4. Create the autounattend ISO and upload it

Windows Setup searches the root of every removable drive for autounattend.xml. The LabConfig registry values let Setup run on a VM without TPM 2.0 and Secure Boot (SeaBIOS keeps the boot fully unattended). The generic Windows 11 Pro installation key selects the Pro edition and skips the key prompt; it does not activate Windows. 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">
   <RunSynchronous>
    <RunSynchronousCommand wcm:action="add"><Order>1</Order><Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
    <RunSynchronousCommand wcm:action="add"><Order>2</Order><Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path></RunSynchronousCommand>
   </RunSynchronous>
   <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><InstallTo><DiskID>0</DiskID><PartitionID>1</PartitionID></InstallTo></OSImage></ImageInstall>
   <UserData><ProductKey><Key>VK7JG-NPHTM-C97JM-9MPGT-3V66T</Key></ProductKey><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><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 40 minutes with the defaults (48 minutes with 2 vCPU / 4 GiB). Most of it is spent after Setup: OOBE downloads updates and restarts once, and Add-WindowsCapability then waits for the Windows Update servicing stack before installing OpenSSH Server. The loop simply keeps waiting; do not interrupt the VM.

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_task prints an error instead of OK: list the latest failed tasks and their status text with pve GET "/nodes/$PVE_NODE/tasks?errors=1" | jq -r '.data[:3][] | "\(.type) \(.id) \(.status)"'.
  • Permission check failed on create: a non-root user must create the VM in a pool it has rights on (add -d pool=<pool>).
  • No SSH after 90 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-WindowsCapability probably could not reach Windows Update; check the IP settings.
  • To start over: pve POST /nodes/$PVE_NODE/qemu/$VMID/status/stop, then pve DELETE "/nodes/$PVE_NODE/qemu/$VMID?purge=1&destroy-unreferenced-disks=1".
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

目次