Unattended Windows 11 install on Proxmox using Ansible

I recently transitioned from VMware ESXi and built a new server for a fresh Proxmox setup. I had no prior experience with Proxmox, but how complicated could a simple Windows installation be? Since installing Windows is usually boring and timeconsuming, I decided to make it more interesting by deploying it autonomously with a one-click setup. What could go wrong …

Prerequisites for Deploying Windows on Proxmox

Ansible Design Principles

Ansible is a radically simple IT automation system. It handles configuration management, application deployment, cloud provisioning, ad-hoc task execution, network automation, and multi-node orchestration. Ansible makes complex changes like zero-downtime rolling updates with load balancers easy. More information on the Ansible website.

  • Have an extremely simple setup process with a minimal learning curve.
  • Manage machines quickly and in parallel.
  • Avoid custom-agents and additional open ports, be agentless by leveraging the existing SSH daemon.
  • Describe infrastructure in a language that is both machine and human friendly.
  • Focus on security and easy auditability/review/rewriting of content.
  • Manage new remote machines instantly, without bootstrapping any software.
  • Allow module development in any dynamic language, not just Python.
  • Be usable as non-root.
  • Be the easiest IT automation system to use, ever.

Installing Ansible on a Rocky 9 VM

First off, i’ve created a Rocky 9 VM which i can use for Ansible. The installation is fairly straight forward

[root@Rocky2 ~]# dnf install python3
[root@Rocky2 ~]# dnf install python3-pip
[root@Rocky2 ~]# dnf install sshpass
[root@Rocky2 ~]# python3 -m venv /home/ansible-win11-proxmox
[root@Rocky2 ~]# source /home/ansible-win11-proxmox/bin/activate
(ansible-win11-proxmox) [root@Rocky2 ~]# /home/ansible-win11-proxmox/bin/python3 -m pip install --upgrade pip
(ansible-win11-proxmox) [root@Rocky2 ~]# pip install ansible<br>Collecting ansible
Downloading ansible-8.7.0-py3-none-any.whl (48.4 MB)

Create an Ansible Playbook

Configure a Ansible playbook to create Windows templates for Proxmox (PVE) based lab environment (somewhat customized for minimal, but easy enough to tweak), prepped for further Ansible automation. Tested with Proxmox v9.0.11 & Ansible 2.15.13

You can download a template of the playbook from my github project >click here<

What each file does (and why you need them)

Inventory file (e.g. inventory.yml)
The inventory defines which machines (hosts) Ansible manages — either individually or grouped.

Example: inventory.yml
---
proxmox:
  hosts:
    10.10.2.77  #Proxmox IP Address
  vars:
    ansible_user: root-proxmox  # Proxmox User
    ansible_password: 'P@ssw0rd' # Proxmox user password
    ansible_ssh_common_args: '-o StrictHostKeyChecking=no'
    ansible_python_interpreter: auto_silent

Variables file / vars (e.g. vars.yml, or via group_vars/, host_vars/)
Many tasks in your playbook need parameters or configuration values (e.g. port numbers, credentials, host-specific paths, etc.) Variables lets you avoid hard-coding them in the playbook.

Example: vars.yml
---

# PVE Template Provisioning Variables
template_name:  Win11 # Proxmox Template Name
pve_storage_id: local # Proxmox Storage location, Default: local
vm_cores: 2
vm_sockets: 2
vm_memory_mb: 4096
vm_os_type: win11
# vm_os_type must be valid value per https://pve.proxmox.com/wiki/Qemu/KVM_Virtual_Machines
drive_size_gb: 60
start_at_boot: no
os_iso_location: local:iso/Win11_25H2_English_x64.iso
format: qcow2
# qcow2 or raw if on lvm


 # EFI disk (for booting via UEFI)
efidisk0:
  storage: "local-lvm"   # Proxmox storage for EFI disk
  size: "4M"             # or "2M" for minimal, "4M" if you need secure boot keys
  format: "raw"
  efitype: "4m"
  pre_enrolled_keys: false  # if you plan to use secure boot

  # TPM for Windows 11 requirement (optional but recommended)
tpmstate0:
  storage: "local-lvm"
  size: "4M"  # TPM state size, adjust as needed
  version: "v2.0"
  format: "qcow"

# Boot settings
boot: "order=ide0;ide1;virtio0;ide2"
cpu_type: "x86-64-v2"
#ootdisk: "virtio0"

# Use UEFI BIOS
bios: "ovmf"

# Specify machine type
machine: "pc-q35-10.1"     # Example Q35 machine; adjust to your Proxmox/QEMU version

# Unattend.xml template variables:
deploy_image: 'Windows Pro 11 N'
# Applicable 2025 options for deploy_image: 
#Windows 11 Home	For typical consumers / home users 
#Windows 11 Pro	For more advanced users, small businesses — adds networking, security, business features. 
#Windows 11 Pro for Workstations	For high-end workstations / power-users needing extra performance / hardware support. 
#Windows 11 Education	For schools / educational institutions / students. 
#Windows 11 Pro Education	Education-oriented but with Pro-level features (for institutions needing management/security + education licensing). 
#Windows 11 Enterprise	For large organizations / companies needing advanced management, security, and deployment tools. 
#Windows 11 SE	A lightweight version — aimed at low-cost / low-end devices, especially for education. 
#Windows 11 IoT Enterprise	For embedded devices / specialized “Internet of Things” (IoT) scenarios / industrial use. 
# The “N” editions (also “KN” for certain markets) are specially made versions that do not include media-related technologies. 
# In practice this means: no built-in media playback apps (no Windows Media Player, no built-in video/music apps, no preinstalled media codecs) by default. 
# Functionally: Aside from missing media features, an “N” edition behaves exactly like the non-N version

computer_name: "*" # If you set <ComputerName>*</ComputerName> — the asterisk is a wildcard that instructs Windows: “Generate a random computer name automatically.
vm_admin_pass: "P@ssw0rd"
vm_time_zone: 'W. Europe Standard Time'  #Time Zone
# Enable with: 'enabled=1,fstrim_cloned_disks=1,type=virtio'
agent: 'enabled=1,fstrim_cloned_disks=1,type=virtio'

Expand

Playbook file (e.g. playbook.yml) — the core “script” that defines what to do
The playbook describes what actions Ansible should perform on the hosts from the inventory — a list of “plays”, each comprising tasks.

Example: autounattend.xml
<?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="offlineServicing"></settings>
	<!-- offlineServicing is the pass used when you are modifying or preparing a Windows image while it’s offline — i.e. not on a running system, but on an image or WIM mount. --> 
	<settings pass="windowsPE">
		<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
			<UILanguage>en-US</UILanguage>
		</component>
		<!-- WinPE Settings: The Core-WinPE component specifies the default language, locale, and other international settings to use during Windows Setup or Windows Deployment Services installations.
			 * loading provided virtio Driver
			 * running Disk Configuration
			 -->		
		<component name="Microsoft-Windows-PnpCustomizationsWinPE" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" processorArchitecture="amd64" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
            <DriverPaths>
                <PathAndCredentials wcm:action="add" wcm:keyValue="1">
                    <Path>e:\drivers\virtio\amd64</Path>
                </PathAndCredentials>
            </DriverPaths>
        </component>
		<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
		 <DiskConfiguration>
			<WillShowUI>Never</WillShowUI>
			<Disk wcm:action="add">
			  <DiskID>0</DiskID>
			  <WillWipeDisk>true</WillWipeDisk>
			  <CreatePartitions>
				<CreatePartition wcm:action="add">
				  <Order>1</Order>
				  <Type>EFI</Type>
				  <Size>100</Size>
				</CreatePartition>
				<CreatePartition wcm:action="add">
				  <Order>2</Order>
				  <Type>MSR</Type>
				  <Size>128</Size>
				</CreatePartition>
				<CreatePartition wcm:action="add">
				  <Order>3</Order>
				  <Type>Primary</Type>
				  <Extend>true</Extend>
				</CreatePartition>
			  </CreatePartitions>
			  <ModifyPartitions>
				<ModifyPartition wcm:action="add">
				  <Order>1</Order>
				  <PartitionID>1</PartitionID>
				  <Label>System</Label>
				  <Format>FAT32</Format>
				</ModifyPartition>
				<ModifyPartition wcm:action="add">
				  <Order>2</Order>
				  <PartitionID>3</PartitionID>
				  <Label>Windows</Label>
				  <Letter>C</Letter>
				  <Format>NTFS</Format>
				</ModifyPartition>
			  </ModifyPartitions>
			</Disk>
		  </DiskConfiguration>
			<!-- Image Install
				* Image configuration from vars.yml
				* preconfigured Product Key ( use your own )
				--> 
			<ImageInstall>
				<OSImage>
					<WillShowUI>Never</WillShowUI>
					<InstallTo>
						<DiskID>0</DiskID>
						<PartitionID>3</PartitionID>
					</InstallTo>
				<InstallToAvailablePartition>false</InstallToAvailablePartition>
					<InstallFrom>
						<MetaData wcm:action="add">
							<Key>/IMAGE/NAME</Key>
							<Value>{{deploy_image}}</Value>
						</MetaData>
					</InstallFrom>
				</OSImage>
			</ImageInstall>
			<UserData>
				<ProductKey>
					<Key>2B87N-8KFHP-DKV6R-Y2C8J-PKCKT</Key>
					<WillShowUI>OnError</WillShowUI>
				</ProductKey>
				<AcceptEula>true</AcceptEula>
			</UserData>
			<UseConfigurationSet>false</UseConfigurationSet>
		</component>
	</settings>
	<!-- Generalize --> 
	<settings pass="generalize"></settings>
	<!-- Speczialize Phase --> 
	<settings pass="specialize">
		<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
			<ComputerName>{{computer_name}}</ComputerName>
			<TimeZone>{{vm_time_zone}}</TimeZone>
		</component>		
	</settings>		
	<settings pass="auditSystem"></settings>
	<settings pass="auditUser"></settings>	
	<!-- out of the box experience aka oobe Phase -->
	<settings pass="oobeSystem">
		<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
			<InputLocale>0407:00000407</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">
		<AutoLogon>
                <Username>administrator</Username>
                <Password>
                    <Value>{{vm_admin_pass}}</Value>
                    <PlainText>true</PlainText>
                </Password>
                <Enabled>true</Enabled>
                <LogonCount>3</LogonCount>
            </AutoLogon> 
			<OOBE>
				<ProtectYourPC>3</ProtectYourPC>
				<HideEULAPage>true</HideEULAPage>
				<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
				<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
			</OOBE>
			<UserAccounts>
                <AdministratorPassword>
                    <Value>{{vm_admin_pass}}</Value>
                    <PlainText>true</PlainText>
                </AdministratorPassword>
            </UserAccounts>
            <FirstLogonCommands>
			    <SynchronousCommand wcm:action="add">																																 
                    <Order>1</Order>
                    <Description>Set Execution Policy 64 Bit</Description>
                    <CommandLine>cmd.exe /c powershell -Command "Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Force"</CommandLine>
                    <RequiresUserInput>true</RequiresUserInput>
                </SynchronousCommand>
                <SynchronousCommand wcm:action="add">															
                    <Order>2</Order>
                    <Description>Set Execution Policy 32 Bit</Description>
                    <CommandLine>C:\Windows\SysWOW64\cmd.exe /c powershell -Command "Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Force"</CommandLine>
                    <RequiresUserInput>true</RequiresUserInput>
                </SynchronousCommand>
                <SynchronousCommand wcm:action="add">
                    <CommandLine>%SystemRoot%\System32\reg.exe ADD HKLM\SYSTEM\CurrentControlSet\Control\Network\NewNetworkWindowOff /f</CommandLine>
                    <Order>3</Order>
                    <Description>Disable Network Discovery Prompt</Description>								   
                </SynchronousCommand>
{% if agent is match("enabled=1") %}
                <SynchronousCommand wcm:action="add">
                    <CommandLine>e:\drivers\virtio\amd64\qemu-ga-x86_64.msi /quiet</CommandLine>
                    <Order>4</Order>
                    <Description>Install qemu-guest-agent</Description>
                </SynchronousCommand>
{% endif %}
                <SynchronousCommand wcm:action="add">
                    <CommandLine>powershell -File e:\scripts\AnsiblePrep.ps1</CommandLine>
                    <Description>Configure Ansible Prep and WinRM</Description>
                    <Order>5</Order>
                </SynchronousCommand>   
			</FirstLogonCommands>
	   </component>
	</settings>
</unattend>
Expand

Run the Playbook from your Ansible Instance

Bash
[root@Rocky2 ansible-win11-proxmox]#  ansible-playbook -i inventory.yml --ask-vault-pass playbook.yml
Vault password:

PLAY [proxmox] ***********************************************************************************************************************

TASK [Get available VMID] ************************************************************************************************************
changed: [10.10.2.77]

TASK [Create temp directory] *********************************************************************************************************
ok: [10.10.2.77]

TASK [Template out autounattend answer file] *****************************************************************************************
ok: [10.10.2.77]

TASK [Copy files for ISO] ************************************************************************************************************
ok: [10.10.2.77]

TASK [Create ISO file] ***************************************************************************************************************
changed: [10.10.2.77]

TASK [Create VM] *********************************************************************************************************************
changed: [10.10.2.77]

TASK [Start VM] **********************************************************************************************************************
changed: [10.10.2.77]

TASK [Wait for VM to boot a little] **************************************************************************************************
Pausing for 8 seconds
(ctrl+C then 'C' = continue early, ctrl+C then 'A' = abort)
ok: [10.10.2.77]

TASK [Click to boot from DVD] ********************************************************************************************************
changed: [10.10.2.77]

TASK [Wait for OS install, config, update, sysprep. Up to 120 min. Monitor status if possible.] **************************************
ASYNC POLL on 10.10.2.77: jid=j313960101483.141117 started=1 finished=0
ASYNC POLL on 10.10.2.77: jid=j313960101483.141117 started=1 finished=0
ASYNC POLL on 10.10.2.77: jid=j313960101483.141117 started=1 finished=0
..
ASYNC OK on 10.10.2.77: jid=j313960101483.141117
changed: [10.10.2.77]

TASK [Remove IDE CD Drives] **********************************************************************************************************
changed: [10.10.2.77]

TASK [Create template] ***************************************************************************************************************
changed: [10.10.2.77]

TASK [Remove Unattend ISO file] ******************************************************************************************************
changed: [10.10.2.77]

TASK [Cleanup temp files used for ISO build] *****************************************************************************************
changed: [10.10.2.77]

TASK [Done] **************************************************************************************************************************
ok: [10.10.2.77] => {
    "msg": "Template build Win11, with VMID 105 complete."
}

PLAY RECAP ***************************************************************************************************************************
10.10.2.77            : ok=15   changed=10   unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Expand

Windows Installation Stages in autounattended.xml

Here is a brief overview of what each “stage” (commonly referred to as a configuration pass) does.

What is a “configuration pass”

  • Windows setup doesn’t run all configuration at once. Instead it runs in a sequence of passes (or phases).
  • In your autounattend.xml file you specify which settings (components) should be applied in which pass.
  • Not every pass has to be used — many automated installs just use a subset depending on how much automation you want.
PassWhen it runs / what part of installWhat you use it for
windowsPEDuring the initial boot from the installation media — the “preinstallation environment” (PE) before Windows is copied to disk* Region / language / locale and keyboard settings for setup.
* Disk configuration — partitioning, formatting, selecting target drive.
* Selecting Windows edition and entering product key (if required).
* Accepting license agreement (EULA) and skipping initial setup dialogs.
offlineServicingBefore the first boot, while Windows image is still offline (i.e. not yet running) — often used for customizing an image before deployment* Inject updates, patches, drivers or extra packages into the Windows image before first run.
generalizeUsed when preparing a reference image (e.g. with Sysprep) to remove machine‑specific data so the image can be reused on other hardware.* Remove unique identifiers (like SIDs), log history, restore points, hardware‑specific drivers, and other hardware‑/machine‑specific configs — so the image becomes “generic.”
specializeFirst boot after Windows is copied, before user login — system‑level configuration before final user setup* System-specific configuration: computer name, network settings, joining a domain/workgroup, registry tweaks.
* Running scripts or commands (e.g. customizing OS, installing software, tweaks) via “RunSynchronous” or similar mechanisms.
auditSystemOnly used when install enters “audit mode” (rather than finishing setup immediately); runs under SYSTEM account before user logon* System‑level customizations — for example additional driver installs or system tweaks — before any user logs in.
auditUserAlso only with audit mode — runs after user logon, under user context* User‑level customizations: user-profile defaults, user‑context setup tasks, perhaps installing user‑specific software.
oobeSystemWhen Windows reaches “out‑of‑box experience” (OOBE) — i.e. final user setup phase (account creation, initial settings), before first login* Create user accounts, set up region/language preferences for user, configure auto‑login or first‑time login tasks.
* Final polish: skip OOBE screens, disable default prompts, finalize privacy settings, run first‑login commands.

Potential Challenges and How to Overcome Them:

1.) WARNING: Running pip as the ‘root’ user can result in broken permissions

See Virtual Environments — Why and When to Use Them

2.) TASK [Get available VMID] FAILED! => {“msg”: “to use the ‘ssh’ connection type with passwords or pkcs11_provider, you must install the sshpass program”}

[root@Rocky2 deploy]# dnf install sshpass

3.) Virtual Machines doesn’t boot / resets straight into Bios

Change CPU type to x86-64-v2-AES or x86-64-v3, depending on the CPU capabilities. There have been recent problems if CPU type is host.

Recommendations for KVM CPU model configuration on x86 hosts — QEMU documentation