Automating VM creation in VMware ESXi with PowerCLI
TL;DR howto automate creation and initial configuration of a VM in VMware ESXi.
Welcome to Part XIII of our cheatsheet series compiled from various courses, labs, stuff we did at work, Google, etc.
Part I: Mimikatz cheatsheet
Part II: Set-Acl cheatsheet
Part III: Get-Acl cheatsheet
Part IV: Enumerating AD cheatsheet
Part V: Windows reverse shells cheatsheet
Part VI: MS Graph PowerShell cheatsheet
Part VII: Hash cracking cheatsheet
Part VIII: The Credential Theft Shuffle
Part IX: SACLs & Querying Collected Logs
Part X: Setting up a simple AD lab in Azure
Part XI: Hyper-V cheatsheet
Part XII: Migrating to openSUSE LEAP
Part XIII: Automating VM creation in ESXi with PowerCLI
Part XIV: PowerCLI cheatsheet
Part XV: Automating VM creation in ESXi with Ansible
Background
I automated initial VM creation and configuration awhile back in Hyper-V and then cleaned it up and made it almost professional around January of this year. I wrote it so one simply runs
New-TestVM -NewVMName "TestFallback" -NewVMIP "106"For example and the function takes care of everything.
- It captures the IP scheme from the Hypervisor itself and then configures the VM to use all the same settings. It sets the last octet according to what’s specified in the command line option ‘-NewVMIP’.
- It captures the username of whoever runs it, prompts for their password, and saves that password in a SecureString to use later to join the VM to the home lab domain. Simply run the function as a Domain Admin, supply the command line options, and go grab a coffee. The VM is up & running, domain joined, and pulling Group Policy when you get back.
Doing it in Hyper-V was easy and intuitive thanks to PowerShell Direct.
However work still uses VMware, for now anyway. License renewal hasn’t come up yet. Therefore I wanted to screw around with managing ESXi with PowerShell and ideally automate initial VM creation and configuration. Once a VM is up & running with the IP scheme set and domain joined I can then simply manage it via WinRM regardless of the hypervisor.
The trick is automating that initial creation & configuration.
PowerCLI
PowerCLI is basically VMware’s version of Hyper-V’s PowerShell Direct. ESXi and PowerCLI themselves are free from Broadcom, however one can only query ESXi without a license. If you try to change any settings, create anything, or even start a VM it doesn’t work.
Someone let me know a little tip; just Google “insite:github.com esxi key”. I was rather ashamed that I hadn’t thought of that.
Generally you don’t want to install PowerCLI on a system that also manages Hyper-V as many of the commands overlap. I simply did everything from my laptop that’s not domain joined. I’m in the habit of RDPing into my domain joined “jumpbox” to then home lab so no issue there. I have only used this laptop to manage Azure VMs and Entra ID.
You can install PowerCLI via:
Install-Module -Name VMware.PowerCLI -Scope CurrentUser -Repository PSGallery -ForceIf you’re just screwing around in the home lab then you can also do:
Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false
Set-PowerCLIConfiguration -Scope User -ParticipateInCEIP $false -Confirm:$falseCreating the HD Template
The tactic I went with is to create the HD from an answer file and the OS’s ISO, mostly because I’m used to doing it that way in Hyper-V. I wrote the Cyber Range using that method as my code downloads the ISO. This kept the range setup to mere MBs.
Get Rich’s stories in your inbox
Join Medium for free to get updates from this writer.
I poked around on Google and realized I could simply use my existing function that creates a VHDX from an answer file and an ISO.
Function Create-VHDX
{
Param
(
[Parameter(Mandatory=$true, Position=0)]
[string] $VMName,
[Parameter(Mandatory=$false, Position=1)]
[string] $IP
)
Write-Host "FYSA: this function must be run as Administrator."
#Creates the VM from a provided ISO & answer file, names it provided VMName
Set-Location "C:\Users\fdhsr\Google Drive\Documents\CompTIA studying\Lab Domain Projects\00 Ideas\Automating ESXi VM deployment"
$isoFilePath = ".\Windows Server 2022 (20348.169.210806–2348.fe_release_svc_refresh_SERVER_EVAL_x64FRE_en-us).iso"
$answerFilePath = ".\ESXi_unattend.xml"
New-Item -ItemType Directory -Path ".\$VMName"
$convertParams = @{
SourcePath = $isoFilePath
SizeBytes = 100GB
Edition = 'Windows Server 2022 Datacenter Evaluation (Desktop Experience)'
VHDFormat = 'VHDX'
VHDPath = ".\$VMName\$VMName.vhdx"
DiskLayout = 'UEFI'
UnattendPath = $answerFilePath
}
Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope CurrentUser
. ".\Convert-WindowsImage (from PS Gallery)\Convert-WindowsImage.ps1"
Convert-WindowsImage @convertParams
}I then used qemu to convert the VHDX to VMDK so VMware can use it. You can download qemu from here.
Function Convert-VHDX_to_VMDK
{
Param
(
[Parameter(Mandatory=$true, Position=0)]
[string] $VHDX,
[Parameter(Mandatory=$true, Position=1)]
[string] $VMDK_Name,
[Parameter(Mandatory=$true, Position=2)]
[string] $Working_Path
)
Set-Location "C:\Program Files\qemu\"
#Don't use this 'subformat=monolithicFlat' unless you want qemu to use up the full 100 GB
.\qemu-img.exe convert -f vhdx -O vmdk -o subformat=streamOptimized "$Working_Path\$VHDX" "$Working_Path\$VMDK_Name"
}I then uploaded the Template VMDK to ESXi via:
Connect-VIServer -Server "192.168.0.98" -User "root" -Password '<password>'
New-Item "vmstore:\ha-datacenter\datastore1\TestTemplate" -ItemType Directory
Get-ChildItem -Path "$WorkingPath\*.vmdk" | Copy-DatastoreItem -Destination "vmstore:\ha-datacenter\datastore1\TestTemplate\" -ForceI used ‘subformat=streamOptimzed’ so that qemu would create a thin provisioned VMDK. This part is crucial and not just to save space on physical HDs. The thin provisioned VHDX is only around 10 GB and the resulting VMDK is 4.77 GB.
Creating the new VM’s HD from the template using different command line options results in a 100 GB template VMDK, which of course takes a lot longer to upload to ESXi as well as wasting space on anything it’s stored on.
Using the template
Everything up to this point simply
- Created a sysprepped VHDX using an answer file and an ISO.
- Converted it to VMDK.
- Uploaded the VMDK to ESXi.
At this point I needed to be able to take that template VMDK and create new VMs from it.
Function New-VMDK
{
Param
(
[Parameter(Mandatory=$true, Position=0)]
[string] $ESXi,
[Parameter(Mandatory=$false, Position=1)]
[string] $VMName
)
$CommandString = "cd /vmfs/volumes/datastore1/TestTemplate && vmkfstools -i TestTemplate.vmdk -d thin ${VMName}.vmdk && mkdir -p ../${VMName} && mv ${VMName}* ../${VMName}/"
# Pass command directly via argument rather than piped stdin
ssh -tt $ESXi $CommandString
}This creates the new VM’s HD, creates a folder on datastore1 named for the VM, and moves the VM’s HD into that folder. It’s run via
New-VMDK -ESXi root@192.168.0.98 -VMName TestServerIICreating the VM
I’ll clean this part up more and make it into a function later. I just wanted to get it working and this is a cheatsheet of how I did that after all.
This part creates the VM without a HD.
#VMware won't let us create the VM without a HD, so we'll make a dummy drive and then immediately remove it
$Network = Get-VirtualPortGroup -Name "VM Network" -Standard
$NewVM = New-VM -Name "TestServerII" -MemoryGB 4 -NumCPU 2 -Datastore "datastore1" -GuestId "windows9Server64Guest" -DiskGB 1 -NetworkName $Network
Get-HardDisk -VM $NewVM | Remove-HardDisk -DeletePermanently:$true -Confirm:$falseThis part attaches our VMDK to the new VM.
By default ESXi attaches the VMDK to the SCCI Controller … which then causes the VM to crash on boot. This took much Googling and trial & error to diagnose, troubleshoot, and fix. This part worked in Hyper-V immediately.
#Gemini wrote this part, but it works. Maps the vmdk to the VM's IDE 0
# 1. Grab the VM object
$VM = Get-VM -Name "TestServerII"
# 2. Build the VirtualDisk configuration spec
$DiskSpec = New-Object VMware.Vim.VirtualMachineConfigSpec
$DiskChange = New-Object VMware.Vim.VirtualDeviceConfigSpec
$DiskChange.Operation = [VMware.Vim.VirtualDeviceConfigSpecOperation]::add
# Define the Virtual Disk
$Disk = New-Object VMware.Vim.VirtualDisk
$Disk.ControllerKey = 200 # Target IDE Controller 0 (Key 200)
$Disk.UnitNumber = 0 # First drive position on IDE 0
# Set the backing to your existing datastore file
$DiskBacking = New-Object VMware.Vim.VirtualDiskFlatVer2BackingInfo
$DiskBacking.FileName = "[datastore1] TestServerII/TestServerII.vmdk"
$DiskBacking.DiskMode = "persistent"
$Disk.Backing = $DiskBacking
$DiskChange.Device = $Disk
$DiskSpec.DeviceChange += $DiskChange
# 3. Apply changes to the VM
$VM.ExtensionData.ReconfigVM($DiskSpec)This part mounts the VMware Tools installer.
#Gemini wrote this part too. Mounts the VM Tools installer.
#PowerCLI and VMware in general really kinda sucked here. Hyper-V does this a lot easier.
#This part must be run between creating the VM and starting it.
$NewVM = Get-VM -Name "TestServerII"
# 1. Initialize configuration spec
$Spec = New-Object VMware.Vim.VirtualMachineConfigSpec
$CdSpec = New-Object VMware.Vim.VirtualDeviceConfigSpec
$CdSpec.Operation = [VMware.Vim.VirtualDeviceConfigSpecOperation]::add
# 2. Instantiate a new Virtual CD-ROM Device
$CdDrive = New-Object VMware.Vim.VirtualCdrom
$CdDrive.ControllerKey = 200 # Attach to IDE Controller 0
$CdDrive.UnitNumber = 1 # Place on Unit 1 (Hard disk is on Unit 0)
# 3. Define ISO Backing
$CdBacking = New-Object VMware.Vim.VirtualCdromIsoBackingInfo
$CdBacking.FileName = "[] /usr/lib/vmware/isoimages/windows.iso"
$CdDrive.Backing = $CdBacking
# 4. Set connection flags so Windows mounts it automatically
$CdDrive.Connectable = New-Object VMware.Vim.VirtualDeviceConnectInfo
$CdDrive.Connectable.Connected = $true
$CdDrive.Connectable.StartConnected = $true
# 5. Assign device to spec and reconfigure VM
$CdSpec.Device = $CdDrive
$Spec.DeviceChange += $CdSpec
$NewVM.ExtensionData.ReconfigVM($Spec)
Initial Configuration
At this point we can start the VM. The answer file tells it to install VMware Tools on first boot hence why we mapped them before starting the VM.
Start-VM -VM $NewVM
# VM's initial local admin credentials:
[string]$userName = ".\Administrator"
[string]$userPassword = 'SuperSecureLocalPassword123!@#'
# Convert to SecureString and build PSCredential object
$secStringPassword = ConvertTo-SecureString $userPassword -AsPlainText -Force
$InitialCredObject = New-Object System.Management.Automation.PSCredential ($userName, $secStringPassword)
#Set the IP scheme and name the VM
Invoke-VMScript -VM "TestServerII" -GuestCredential $InitialCredObject -ScriptType PowerShell -ScriptText {
$Adapter = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | Select-Object -First 1
New-NetIPAddress -InterfaceAlias $Adapter.Name -IPAddress "192.168.0.112" -PrefixLength 24 -DefaultGateway "192.168.0.1"
Set-DnsClientServerAddress -InterfaceAlias $Adapter.Name -ServerAddresses "192.168.0.101","192.168.0.102","192.168.0.103","192.168.0.104","8.8.8.8"
#Disable IPv6
$NIC = (Get-NetAdapter).InterfaceAlias
Disable-NetAdapterBinding -InterfaceAlias $NIC -ComponentID ms_tcpip6
Install-WindowsFeature -Name "RSAT" -IncludeAllSubFeature
Rename-Computer -NewName "TestServerII" -Force
Restart-Computer -Force
}
Start-Sleep -Seconds 120We then join the VM to the home lab domain.
#This almost had to be a function as PowerCLI doesn't support $using:variable
#The ` character escapes variables in the string being passed to the VM so Windows ignores them locally
Function Add-VMToDomain
{
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$true)]
[string]$VMName,
[Parameter(Mandatory=$true)]
[string]$DomainName,
[Parameter(Mandatory=$true)]
[string]$DomainUser,
[Parameter(Mandatory=$true)]
[string]$DomainPassword
)
# Expand local string variables into script text before passing to ESXi
$ScriptPayload = "
netsh advfirewall firewall set rule group='Network Discovery' new enable=Yes
`$SecPass = ConvertTo-SecureString '$DomainPassword' -AsPlainText -Force
`$GuestDomainCred = New-Object System.Management.Automation.PSCredential ('$DomainUser', `$SecPass)
Add-Computer -DomainName '$DomainName' -Credential `$GuestDomainCred -Restart -Force
"
Invoke-VMScript -VM $VMName -GuestCredential $InitialCredObject -ScriptType PowerShell -ScriptText $ScriptPayload
}Confirm via
Invoke-VMScript -VM "TestServerII" -GuestCredential $InitialCredObject -ScriptType PowerShell -ScriptText {ipconfig /all}We can also see our VM’s information in the ESXi web UI, if you’re into GUIs.
Summary
I’ll wrap the process to create and configure a new VM from the template up in a function with command line options for VM name, last IP octet, and credentials to join the domain. I already captured the relevant information in variables from ESXi itself.
#Capture IP scheme from ESXi
$GW = (Get-VMHostNetwork | Select-Object *).VMKernelGateway
$Subnet = (Get-VMHostNetworkAdapter -Name vmk0 | Select-Object *).SubnetMask
$DNS = (Get-VMHostNetwork | Select-Object *).DnsAddress
$DomainName = (Get-VMHost "192.168.0.98" | Get-VMHostAuthentication).DomainA future project is to write a Config-NIC version for ESXi. Another future project is automating VM creation & configuration in Ansible.
Well those are future projects for future howtos.
References
PowerCLI howto: https://www.techrepublic.com/article/how-to-manage-esxi-hosts-remotely-with-powercli/








