Deploy Linux VMs on vSphere with cloud-init and GuestInfo: A Complete PowerCLI Guide

TL;DR

PowerCLI can clone a Linux template, write cloud-init metadata and user data into vSphere GuestInfo settings, and power on a fully initialized virtual machine without attaching a seed ISO or maintaining a traditional guest customization specification.

The reliable deployment sequence is:

Prepare a clean Linux template with cloud-init and open-vm-tools.

Clone the template while the new VM remains powered off.

Connect the correct vSphere port group.

Read the virtual NIC MAC address.

Generate cloud-init metadata that matches that MAC address.

Add the hostname, static network, DNS servers, user account, and SSH public key.

Encode the metadata and user data as base64 or gzip+base64.

Write the encoded payloads to the VM’s guestinfo.* advanced settings.

Power on the VM.

Validate VMware Tools, cloud-init, networking, DNS, and SSH access.

The reusable PowerCLI script in this guide implements that workflow for Ubuntu, Rocky Linux, and Photon OS.

Introduction

Linux VM deployment on vSphere often stops halfway between manual administration and full automation. PowerCLI clones the VM, assigns compute resources, and connects a port group, but an administrator still logs in to set the hostname, configure a static IP address, install an SSH key, and remove state inherited from the template.

That last manual step is unnecessary.

The cloud-init VMware datasource can retrieve first-boot configuration from vSphere GuestInfo. PowerCLI can populate those GuestInfo values as VM advanced settings before the first power-on. The Linux guest then consumes the configuration through open-vm-tools and applies the requested identity, networking, users, SSH access, files, and commands.

This creates a clean division of responsibility:

vSphere manages compute, memory, storage, placement, and virtual networking.

PowerCLI creates the VM and publishes first-boot intent.

GuestInfo transports that intent into the guest.

cloud-init converts the intent into Linux configuration.

The Linux distribution renders the requested settings through its native network and service-management stack.

The method is underused because many administrators associate cloud-init only with public-cloud images. In practice, it is equally useful for private vSphere environments when templates and first-boot workflows are managed carefully.

What You Will Build

The implementation in this guide can:

Clone a powered-off Linux template with PowerCLI.

Assign CPU, memory, datastore, cluster, folder, and network placement.

Connect either a distributed port group or a standard port group.

Create a unique cloud-init instance ID.

Match network configuration to the actual vSphere NIC MAC address.

Configure a static IPv4 address, default route, and DNS servers.

Use DHCP when static networking is not required.

Create a non-root Linux administrator.

Install a public SSH key.

Disable SSH password authentication.

Encode cloud-init data as base64 or gzip+base64.

Create or update the required guestinfo.* settings.

Delay power-on until the payload is attached.

Validate VMware Tools and the guest-reported IP address.

Scale deployments through CSV input.

The example assumes one virtual NIC. Multi-NIC deployments follow the same design, but each adapter should receive a separate network stanza matched to its MAC address.

How GuestInfo and cloud-init Work Together

The critical boundary is the first boot. PowerCLI must finish cloning the VM and writing the GuestInfo values before cloud-init begins processing the new instance.

The GuestInfo transport normally uses four advanced settings:

GuestInfo setting
Purpose

guestinfo.metadata
Instance identity, hostname, VMware datasource options, and network configuration

guestinfo.metadata.encoding
Declares how the metadata value is encoded

guestinfo.userdata
Users, SSH keys, files, package actions, and first-boot commands

guestinfo.userdata.encoding
Declares how the user-data value is encoded

Optional vendor data can be stored in guestinfo.vendordata, but per-VM metadata and user data are sufficient for this deployment pattern.

The encoding value must match the actual byte transformation. If PowerCLI compresses a payload but declares plain base64, cloud-init cannot decode it correctly.

Prerequisites and Assumptions

A successful script cannot compensate for an unprepared template, an ambiguous port group, or an address that has already been allocated.

Requirement
Practical expectation

vCenter access
Permission to clone templates, edit virtual hardware, modify advanced settings, connect networks, and power on VMs

PowerCLI
A supported PowerCLI installation with the vSphere modules available

vSphere inventory
Template, cluster, resource pool, datastore, port group, and optional VM folder

Linux template
cloud-init, open-vm-tools, and an SSH server installed and enabled

Template state
Powered off, generalized, and cleaned for a new cloud-init instance

Network data
Reserved address, valid CIDR prefix, gateway, DNS servers, and VLAN reachability

SSH key
A valid public key stored on one line

Console access
Available during initial template and network testing

Deployment order
GuestInfo data written before the new VM’s first power-on

The examples use IPv4. IPv6, network bonds, bridges, VLAN interfaces, multiple gateways, and policy routing require expanded cloud-init network metadata.

The VM name is also used as the Linux hostname. Keep it lowercase, shorter than 64 characters, and limited to letters, digits, and internal hyphens.

Prepare the Linux Templates

Template preparation is the foundation of the workflow. Many apparent PowerCLI failures are actually caused by stale cloud-init state, missing VMware Tools, disabled network rendering, or a Linux image that does not contain the VMware datasource.

Shared Template Checklist

Confirm the required components before converting the VM into a template:

cloud-init –version
systemctl is-enabled cloud-init-local.service
systemctl is-enabled cloud-init.service
systemctl is-enabled cloud-config.service
systemctl is-enabled cloud-final.service
vmware-toolbox-cmd -v
ps -ef | grep -E ‘vmtoolsd|cloud-init’ | grep -v grep

Before the final shutdown, remove deployment-specific cloud-init state:

sudo cloud-init clean –logs –machine-id –seed
sudo rm -f /etc/ssh/ssh_host_*
sudo rm -f /var/lib/systemd/random-seed
sudo sync
sudo shutdown -h now

Check cloud-init clean –help on the exact distribution before standardizing these options. Older cloud-init packages may not support every parameter.

Deleting SSH host keys is appropriate only when the distribution is configured to regenerate them during first boot. Confirm that behavior before turning the VM into a template.

Also identify stale network files:

sudo find /etc -maxdepth 4 -type f
( -name ‘*.network’ -o
-name ‘*.networkd’ -o
-name ‘*.nmconnection’ -o
-name ‘*.yaml’ )
-print

Do not delete every network file. Remove or generalize only files that bind the template to its old IP address, MAC address, interface name, or default route.

Ubuntu Template Notes

Ubuntu cloud images are designed for first-boot initialization and are usually the simplest starting point. A manually installed Ubuntu Server VM can also work when it has been generalized correctly.

Validate the required packages:

dpkg-query -W cloud-init open-vm-tools openssh-server
systemctl is-enabled open-vm-tools.service
systemctl is-enabled ssh.service

Ubuntu commonly renders cloud-init network version 2 configuration through Netplan. Check for hard-coded template networking:

sudo ls -la /etc/netplan
sudo netplan get

A template-specific Netplan file should not preserve the source VM’s address or MAC address unless it is explicitly designed to be replaced.

Rocky Linux Template Notes

A Rocky Linux Generic Cloud image is preferable to assuming that a minimal ISO installation already contains a complete cloud-init stack.

Validate the packages and services:

rpm -q cloud-init open-vm-tools NetworkManager openssh-server
systemctl is-enabled vmtoolsd.service
systemctl is-enabled NetworkManager.service
systemctl is-enabled sshd.service

Rocky Linux normally renders cloud-init networking through NetworkManager. Review existing connection profiles:

sudo nmcli connection show
sudo ls -la /etc/NetworkManager/system-connections

Remove or generalize stale profiles tied to the source VM’s MAC address, interface name, or IP address.

Do not disable NetworkManager to simplify cloud-init. That replaces one automation problem with a larger operating-system support problem.

Photon OS Template Notes

Photon OS is a strong option for compact vSphere workloads, but template validation is especially important because older Photon documentation and images may use different datasource terminology.

Check the required packages and services:

rpm -q cloud-init open-vm-tools openssh-server
systemctl is-enabled vmtoolsd.service
systemctl is-enabled sshd.service

Photon OS may contain a cloud-init configuration file that explicitly disables network rendering. Check for that condition:

sudo grep -R “config: disabled”
/etc/cloud/cloud.cfg
/etc/cloud/cloud.cfg.d

A file similar to the following can prevent GuestInfo network metadata from being applied:

/etc/cloud/cloud.cfg.d/99-disable-networking-config.cfg

Remove or revise that override only after confirming another provisioning workflow does not require it.

Some Photon material refers to VMwareGuestInfo, while newer cloud-init releases refer to the VMware datasource. Use the packages and logs installed in the actual template as the source of truth.

Build Static Network Metadata

For the GuestInfo transport, place the network definition inside metadata under the top-level network key.

A version 2 network document can match the virtual NIC by MAC address:

instance-id: app01-9b4c5dce
local-hostname: app01
wait-on-network:
ipv4: true
ipv6: false
redact:
– userdata
network:
version: 2
ethernets:
primary:
match:
macaddress: “00:50:56:aa:bb:cc”
dhcp4: false
dhcp6: false
addresses:
– 10.20.30.41/24
routes:
– to: default
via: 10.20.30.1
nameservers:
addresses:
– 10.20.0.10
– 10.20.0.11

Each setting has an operational purpose:

instance-id uniquely identifies this cloud-init instance.

local-hostname sets the Linux hostname.

wait-on-network delays dependent processing until IPv4 networking is available.

redact instructs the datasource to clear selected GuestInfo transport values after processing.

match.macaddress binds the configuration to the actual vSphere NIC.

addresses defines the static address and prefix.

routes defines the default gateway.

nameservers defines the DNS resolver list.

MAC-based matching avoids assumptions about whether the guest calls its interface eth0, ens160, enp1s0, or something else.

For DHCP, the network section is smaller:

network:
version: 2
ethernets:
primary:
match:
macaddress: “00:50:56:aa:bb:cc”
dhcp4: true
dhcp6: false

DHCP does not eliminate the need for a unique instance ID. It only changes how the VM obtains its address.

Build User Data for SSH Access

User data begins with the #cloud-config header.

The following example creates a non-root administrator, installs a public SSH key, disables password-based SSH authentication, and writes a deployment record:

#cloud-config
preserve_hostname: false
users:
– name: vmadmin
gecos: vSphere automation administrator
shell: /bin/bash
lock_passwd: true
sudo: “ALL=(ALL) NOPASSWD:ALL”
ssh_authorized_keys:
– “ssh-ed25519 AAAAC3ExamplePublicKeyOnly automation-admin”
ssh_pwauth: false
disable_root: true
write_files:
– path: /etc/dtd-build-info
owner: root:root
permissions: “0644”
content: |
vm_name=app01
instance_id=app01-9b4c5dce
provisioner=PowerCLI-GuestInfo
final_message: “cloud-init completed for app01”

Only the public SSH key belongs in user data. Never inject a private SSH key.

The NOPASSWD sudo policy is convenient for automation but may not match production security requirements. Replace it with your organization’s privilege model, command restrictions, centralized identity controls, or configuration-management policy.

Encode the Payloads

GuestInfo advanced settings store strings. Encoding prevents line-ending, whitespace, and quoting changes while YAML moves through PowerShell, PowerCLI, vCenter, and VM extraConfig.

Simple base64 encoding looks like this:

$bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($UserData)
$encodedUserData = [Convert]::ToBase64String($bytes)

The $false value prevents PowerShell from adding a UTF-8 byte-order mark.

Larger payloads can use gzip+base64:

function ConvertTo-GuestInfoPayload {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Text,

[Parameter(Mandatory)]
[ValidateSet(‘base64’, ‘gzip+base64’)]
[string]$Encoding
)

$bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text)

if ($Encoding -eq ‘base64’) {
return [Convert]::ToBase64String($bytes)
}

$outputStream = [System.IO.MemoryStream]::new()

try {
$gzipStream = [System.IO.Compression.GZipStream]::new(
$outputStream,
[System.IO.Compression.CompressionLevel]::Optimal,
$true
)

try {
$gzipStream.Write($bytes, 0, $bytes.Length)
}
finally {
$gzipStream.Dispose()
}

return [Convert]::ToBase64String($outputStream.ToArray())
}
finally {
$outputStream.Dispose()
}
}

Base64 and gzip provide transport encoding, not encryption. Administrators with sufficient vSphere permissions may be able to inspect VM advanced settings.

Deploy a Linux VM with PowerCLI

The reusable script below performs the complete single-NIC workflow.

It intentionally leaves a failed clone in place instead of deleting it automatically. Preserving the failed VM protects troubleshooting evidence and prevents an automation error from deleting a partially recoverable deployment.

Save the script as Deploy-LinuxVmWithCloudInit.ps1.

[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$VMName,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$TemplateName,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$ClusterName,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$DatastoreName,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$PortgroupName,

[ValidateSet(‘Auto’, ‘Distributed’, ‘Standard’)]
[string]$PortgroupType = ‘Auto’,

[string]$VMHostName,

[string]$FolderName,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$SshPublicKey,

[ValidatePattern(‘^[a-z_][a-z0-9_-]{0,31}$’)]
[string]$LinuxUser = ‘vmadmin’,

[ValidateSet(‘Static’, ‘Dhcp’)]
[string]$NetworkMode = ‘Static’,

[string]$IpAddressCidr,

[string]$Gateway,

[string[]]$DnsServers = @(
‘10.20.0.10’,
‘10.20.0.11’
),

[ValidateRange(1, 256)]
[int]$Cpu = 2,

[ValidateRange(1, 4096)]
[int]$MemoryGB = 4,

[ValidateSet(‘base64’, ‘gzip+base64’)]
[string]$Encoding = ‘gzip+base64’,

[switch]$PowerOn
)

Set-StrictMode -Version Latest
$ErrorActionPreference = ‘Stop’

function Get-UniqueObject {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object[]]$InputObject,

[Parameter(Mandatory)]
[string]$Description
)

if ($InputObject.Count -eq 0) {
throw “No $Description was found.”
}

if ($InputObject.Count -gt 1) {
throw “More than one $Description was found.”
}

return $InputObject[0]
}

function Assert-IPv4Address {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Value,

[Parameter(Mandatory)]
[string]$FieldName
)

$parsedAddress = $null

$valid = [System.Net.IPAddress]::TryParse(
$Value,
[ref]$parsedAddress
)

if (
-not $valid -or
$parsedAddress.AddressFamily -ne
[System.Net.Sockets.AddressFamily]::InterNetwork
) {
throw “$FieldName must be a valid IPv4 address: $Value”
}
}

function Assert-IPv4Cidr {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Value
)

$parts = $Value.Split(‘/’)

if ($parts.Count -ne 2) {
throw ‘IpAddressCidr must use address/prefix format.’
}

Assert-IPv4Address `
-Value $parts[0] `
-FieldName ‘IpAddressCidr’

$prefix = 0

if (
-not [int]::TryParse($parts[1], [ref]$prefix) -or
$prefix -lt 1 -or
$prefix -gt 32
) {
throw ‘The CIDR prefix must be between 1 and 32.’
}
}

function ConvertTo-GuestInfoPayload {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Text,

[Parameter(Mandatory)]
[ValidateSet(‘base64’, ‘gzip+base64’)]
[string]$Encoding
)

$bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Text)

if ($Encoding -eq ‘base64’) {
return [Convert]::ToBase64String($bytes)
}

$outputStream = [System.IO.MemoryStream]::new()

try {
$gzipStream = [System.IO.Compression.GZipStream]::new(
$outputStream,
[System.IO.Compression.CompressionLevel]::Optimal,
$true
)

try {
$gzipStream.Write($bytes, 0, $bytes.Length)
}
finally {
$gzipStream.Dispose()
}

return [Convert]::ToBase64String(
$outputStream.ToArray()
)
}
finally {
$outputStream.Dispose()
}
}

function Set-GuestInfoValue {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
$VM,

[Parameter(Mandatory)]
[string]$Name,

[Parameter(Mandatory)]
[AllowEmptyString()]
[string]$Value
)

$existingSetting = Get-AdvancedSetting `
-Entity $VM `
-Name $Name `
-ErrorAction SilentlyContinue

if ($null -ne $existingSetting) {
$existingSetting |
Set-AdvancedSetting `
-Value $Value `
-Confirm:$false |
Out-Null
}
else {
New-AdvancedSetting `
-Entity $VM `
-Name $Name `
-Value $Value `
-Confirm:$false |
Out-Null
}
}

if (
$VMName.Length -gt 63 -or
$VMName -notmatch
‘^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$’
) {
throw ‘VMName must be a valid lowercase DNS label.’
}

$SshPublicKey = $SshPublicKey.Trim()

if (
$SshPublicKey.Contains(“`n”) -or
$SshPublicKey.Contains(“`r”)
) {
throw ‘SshPublicKey must be stored on one line.’
}

if (
$SshPublicKey -notmatch
‘^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521))s+S+’
) {
throw ‘SshPublicKey does not look like a valid OpenSSH public key.’
}

if ($NetworkMode -eq ‘Static’) {
if ([string]::IsNullOrWhiteSpace($IpAddressCidr)) {
throw ‘IpAddressCidr is required for static networking.’
}

if ([string]::IsNullOrWhiteSpace($Gateway)) {
throw ‘Gateway is required for static networking.’
}

if ($DnsServers.Count -eq 0) {
throw ‘At least one DNS server is required.’
}

Assert-IPv4Cidr -Value $IpAddressCidr
Assert-IPv4Address -Value $Gateway -FieldName ‘Gateway’

foreach ($dnsServer in $DnsServers) {
Assert-IPv4Address `
-Value $dnsServer `
-FieldName ‘DnsServers’
}
}

$existingVMs = @(
Get-VM -Name $VMName -ErrorAction SilentlyContinue |
Where-Object Name -eq $VMName
)

if ($existingVMs.Count -gt 0) {
throw “A VM named $VMName already exists.”
}

$template = Get-UniqueObject `
-Description “template named $TemplateName” `
-InputObject @(
Get-Template `
-Name $TemplateName `
-ErrorAction SilentlyContinue |
Where-Object Name -eq $TemplateName
)

$cluster = Get-UniqueObject `
-Description “cluster named $ClusterName” `
-InputObject @(
Get-Cluster `
-Name $ClusterName `
-ErrorAction SilentlyContinue |
Where-Object Name -eq $ClusterName
)

$datastore = Get-UniqueObject `
-Description “datastore named $DatastoreName” `
-InputObject @(
Get-Datastore `
-Name $DatastoreName `
-ErrorAction SilentlyContinue |
Where-Object Name -eq $DatastoreName
)

$resourcePool = Get-UniqueObject `
-Description “root resource pool for $ClusterName” `
-InputObject @(
Get-ResourcePool -Location $cluster |
Where-Object Name -eq ‘Resources’
)

$folder = $null

if (-not [string]::IsNullOrWhiteSpace($FolderName)) {
$folder = Get-UniqueObject `
-Description “VM folder named $FolderName” `
-InputObject @(
Get-Folder `
-Name $FolderName `
-Type VM `
-ErrorAction SilentlyContinue |
Where-Object Name -eq $FolderName
)
}

$vmHost = $null
$distributedPortgroups = @(
Get-VDPortgroup `
-Name $PortgroupName `
-ErrorAction SilentlyContinue |
Where-Object Name -eq $PortgroupName
)

$standardPortgroups = @()

if (-not [string]::IsNullOrWhiteSpace($VMHostName)) {
$vmHost = Get-UniqueObject `
-Description “VM host named $VMHostName” `
-InputObject @(
Get-VMHost `
-Name $VMHostName `
-ErrorAction SilentlyContinue |
Where-Object Name -eq $VMHostName
)

$hostCluster = Get-Cluster -VMHost $vmHost

if ($hostCluster.Id -ne $cluster.Id) {
throw “$VMHostName is not a member of $ClusterName.”
}

$standardPortgroups = @(
Get-VirtualPortGroup `
-VMHost $vmHost `
-Name $PortgroupName `
-ErrorAction SilentlyContinue |
Where-Object Name -eq $PortgroupName
)
}

$portgroup = $null

switch ($PortgroupType) {
‘Distributed’ {
$portgroup = Get-UniqueObject `
-Description “distributed port group named $PortgroupName” `
-InputObject $distributedPortgroups
}

‘Standard’ {
if ($null -eq $vmHost) {
throw ‘VMHostName is required for a standard port group.’
}

$portgroup = Get-UniqueObject `
-Description “standard port group named $PortgroupName” `
-InputObject $standardPortgroups
}

‘Auto’ {
if (
$distributedPortgroups.Count -eq 1 -and
$standardPortgroups.Count -eq 1
) {
throw (
“Both a distributed and standard port group named ” +
“$PortgroupName were found. Specify PortgroupType.”
)
}

if ($distributedPortgroups.Count -eq 1) {
$portgroup = $distributedPortgroups[0]
}
elseif ($standardPortgroups.Count -eq 1) {
$portgroup = $standardPortgroups[0]
}
elseif ($null -eq $vmHost) {
throw (
“No distributed port group named $PortgroupName was found. ” +
“Provide VMHostName to evaluate standard port groups.”
)
}
else {
throw “No unique port group named $PortgroupName was found.”
}
}
}

$newVmParameters = @{
Name = $VMName
Template = $template
ResourcePool = $resourcePool
Datastore = $datastore
ErrorAction = ‘Stop’
}

if ($null -ne $folder) {
$newVmParameters.Location = $folder
}

if ($null -ne $vmHost) {
$newVmParameters.VMHost = $vmHost
}

$vm = $null

try {
Write-Verbose “Cloning $TemplateName to $VMName.”

$vm = New-VM @newVmParameters

$vm = Set-VM `
-VM $vm `
-NumCpu $Cpu `
-MemoryGB $MemoryGB `
-Confirm:$false

$networkAdapters = @(Get-NetworkAdapter -VM $vm)

if ($networkAdapters.Count -ne 1) {
throw (
“This script expects one network adapter. ” +
“Found $($networkAdapters.Count).”
)
}

$networkAdapter = $networkAdapters[0] |
Set-NetworkAdapter `
-Portgroup $portgroup `
-StartConnected:$true `
-Confirm:$false

$macAddress = $networkAdapter.MacAddress.ToLowerInvariant()
$instanceId = “$VMName-$(New-Guid)”

if ($NetworkMode -eq ‘Static’) {
$dnsYaml = (
$DnsServers |
ForEach-Object {
” – $_”
}
) -join “`n”

$metadata = @”
instance-id: $instanceId
local-hostname: $VMName
wait-on-network:
ipv4: true
ipv6: false
redact:
– userdata
network:
version: 2
ethernets:
primary:
match:
macaddress: “$macAddress”
dhcp4: false
dhcp6: false
addresses:
– $IpAddressCidr
routes:
– to: default
via: $Gateway
nameservers:
addresses:
$dnsYaml
“@
}
else {
$metadata = @”
instance-id: $instanceId
local-hostname: $VMName
wait-on-network:
ipv4: true
ipv6: false
redact:
– userdata
network:
version: 2
ethernets:
primary:
match:
macaddress: “$macAddress”
dhcp4: true
dhcp6: false
“@
}

$yamlSshPublicKey = $SshPublicKey.Replace(“‘”, “””)

$userData = @”
#cloud-config
preserve_hostname: false
users:
– name: $LinuxUser
gecos: vSphere automation administrator
shell: /bin/bash
lock_passwd: true
sudo: “ALL=(ALL) NOPASSWD:ALL”
ssh_authorized_keys:
– ‘$yamlSshPublicKey’
ssh_pwauth: false
disable_root: true
write_files:
– path: /etc/dtd-build-info
owner: root:root
permissions: “0644”
content: |
vm_name=$VMName
instance_id=$instanceId
provisioner=PowerCLI-GuestInfo
final_message: “cloud-init completed for $VMName”
“@

$encodedMetadata = ConvertTo-GuestInfoPayload `
-Text $metadata `
-Encoding $Encoding

$encodedUserData = ConvertTo-GuestInfoPayload `
-Text $userData `
-Encoding $Encoding

Set-GuestInfoValue `
-VM $vm `
-Name ‘guestinfo.metadata’ `
-Value $encodedMetadata

Set-GuestInfoValue `
-VM $vm `
-Name ‘guestinfo.metadata.encoding’ `
-Value $Encoding

Set-GuestInfoValue `
-VM $vm `
-Name ‘guestinfo.userdata’ `
-Value $encodedUserData

Set-GuestInfoValue `
-VM $vm `
-Name ‘guestinfo.userdata.encoding’ `
-Value $Encoding

$reportedIpAddresses = @()

if ($PowerOn) {
Start-VM -VM $vm | Out-Null

Wait-Tools `
-VM $vm `
-TimeoutSeconds 600 |
Out-Null

$vmView = Get-View -Id $vm.Id

$reportedIpAddresses = @(
$vmView.Guest.Net |
ForEach-Object {
$_.IpAddress
} |
Where-Object {
$_ -and
$_ -ne ‘127.0.0.1’ -and
$_ -notmatch ‘^fe80:’
} |
Sort-Object -Unique
)
}

[pscustomobject]@{
VMName = $VMName
Template = $TemplateName
Cluster = $ClusterName
Datastore = $DatastoreName
Portgroup = $PortgroupName
MacAddress = $macAddress
InstanceId = $instanceId
NetworkMode = $NetworkMode
RequestedAddress = $IpAddressCidr
GuestInfoEncoding = $Encoding
PowerState = (Get-VM -Id $vm.Id).PowerState
ReportedIpAddresses = $reportedIpAddresses
}
}
catch {
if ($null -ne $vm) {
Write-Warning (
“Deployment failed after VM creation. ” +
“The VM was left in place for investigation: $VMName”
)
}

throw
}

Understand the Script Behavior

The script performs several checks before it creates anything:

The VM name must be a valid lowercase DNS label.

The public SSH key must be stored on one line.

Static network values must be valid IPv4 values.

The VM name must not already exist.

The template, cluster, datastore, resource pool, and folder must resolve uniquely.

Port group ambiguity causes a failure instead of an arbitrary selection.

A standard port group requires a target ESXi host.

The source template must contain exactly one network adapter.

After cloning, the script:

Assigns CPU and memory.

Connects the intended port group.

Retrieves the generated vSphere MAC address.

Builds metadata and user data.

Encodes both documents.

Creates or updates the four GuestInfo settings.

Powers on the VM when -PowerOn is specified.

Waits for VMware Tools.

Returns deployment and guest-network information.

Wait-Tools proves that VMware Tools loaded. It does not prove that cloud-init completed, SSH is available, or every user-data module succeeded.

Deploy Ubuntu

Connect to vCenter and load a public SSH key:

$credential = Get-Credential

Connect-VIServer `
-Server ‘vcsa01.lab.local’ `
-Credential $credential

$publicKey = (
Get-Content `
-Path “$HOME/.ssh/id_ed25519.pub” `
-Raw
).Trim()

Deploy an Ubuntu VM:

.Deploy-LinuxVmWithCloudInit.ps1 `
-VMName ‘ubuntu-app01’ `
-TemplateName ‘tpl-ubuntu-cloudinit’ `
-ClusterName ‘cluster-prod-01’ `
-DatastoreName ‘vsanDatastore’ `
-PortgroupName ‘dvpg-app-prod’ `
-PortgroupType Distributed `
-FolderName ‘Linux-Production’ `
-LinuxUser ‘vmadmin’ `
-SshPublicKey $publicKey `
-NetworkMode Static `
-IpAddressCidr ‘10.20.30.41/24’ `
-Gateway ‘10.20.30.1’ `
-DnsServers ‘10.20.0.10’, ‘10.20.0.11’ `
-Cpu 2 `
-MemoryGB 4 `
-PowerOn `
-Verbose

Ubuntu should render the requested network configuration through its Netplan-based network stack.

Deploy Rocky Linux

Deploy a Rocky Linux VM with the same script:

.Deploy-LinuxVmWithCloudInit.ps1 `
-VMName ‘rocky-app01’ `
-TemplateName ‘tpl-rocky-cloudinit’ `
-ClusterName ‘cluster-prod-01’ `
-DatastoreName ‘vsanDatastore’ `
-PortgroupName ‘dvpg-app-prod’ `
-PortgroupType Distributed `
-FolderName ‘Linux-Production’ `
-LinuxUser ‘vmadmin’ `
-SshPublicKey $publicKey `
-NetworkMode Static `
-IpAddressCidr ‘10.20.30.42/24’ `
-Gateway ‘10.20.30.1’ `
-DnsServers ‘10.20.0.10’, ‘10.20.0.11’ `
-Cpu 2 `
-MemoryGB 4 `
-PowerOn `
-Verbose

Rocky Linux should render the network configuration as a NetworkManager connection profile.

Deploy Photon OS

Deploy a Photon OS VM:

.Deploy-LinuxVmWithCloudInit.ps1 `
-VMName ‘photon-app01’ `
-TemplateName ‘tpl-photon-cloudinit’ `
-ClusterName ‘cluster-prod-01’ `
-DatastoreName ‘vsanDatastore’ `
-PortgroupName ‘dvpg-app-prod’ `
-PortgroupType Distributed `
-FolderName ‘Linux-Production’ `
-LinuxUser ‘vmadmin’ `
-SshPublicKey $publicKey `
-NetworkMode Static `
-IpAddressCidr ‘10.20.30.43/24’ `
-Gateway ‘10.20.30.1’ `
-DnsServers ‘10.20.0.10’, ‘10.20.0.11’ `
-Cpu 2 `
-MemoryGB 2 `
-PowerOn `
-Verbose

Confirm that the Photon image provides /bin/bash. Change the cloud-init user shell when the template uses another supported shell path.

Deploy with DHCP

The script also supports DHCP:

.Deploy-LinuxVmWithCloudInit.ps1 `
-VMName ‘ubuntu-test01’ `
-TemplateName ‘tpl-ubuntu-cloudinit’ `
-ClusterName ‘cluster-lab-01’ `
-DatastoreName ‘lab-datastore-01’ `
-PortgroupName ‘dvpg-lab-dhcp’ `
-PortgroupType Distributed `
-LinuxUser ‘vmadmin’ `
-SshPublicKey $publicKey `
-NetworkMode Dhcp `
-Cpu 2 `
-MemoryGB 4 `
-PowerOn

DHCP is useful for development and test environments, but production deployments should still integrate with DHCP reservations, DNS registration, asset ownership, and address-management controls.

Scale the Deployment with CSV Input

A CSV file can turn the same script into a controlled batch workflow.

Example linux-vms.csv:

VMName,TemplateName,ClusterName,DatastoreName,PortgroupName,FolderName,LinuxUser,IpAddressCidr,Gateway,DnsServers,Cpu,MemoryGB
ubuntu-app02,tpl-ubuntu-cloudinit,cluster-prod-01,vsanDatastore,dvpg-app-prod,Linux-Production,vmadmin,10.20.30.44/24,10.20.30.1,10.20.0.10;10.20.0.11,2,4
rocky-app02,tpl-rocky-cloudinit,cluster-prod-01,vsanDatastore,dvpg-app-prod,Linux-Production,vmadmin,10.20.30.45/24,10.20.30.1,10.20.0.10;10.20.0.11,2,4
photon-app02,tpl-photon-cloudinit,cluster-prod-01,vsanDatastore,dvpg-app-prod,Linux-Production,vmadmin,10.20.30.46/24,10.20.30.1,10.20.0.10;10.20.0.11,2,2

Batch wrapper:

$publicKey = (
Get-Content `
-Path “$HOME/.ssh/id_ed25519.pub” `
-Raw
).Trim()

$results = Import-Csv -Path ‘.linux-vms.csv’ |
ForEach-Object {
try {
.Deploy-LinuxVmWithCloudInit.ps1 `
-VMName $_.VMName `
-TemplateName $_.TemplateName `
-ClusterName $_.ClusterName `
-DatastoreName $_.DatastoreName `
-PortgroupName $_.PortgroupName `
-PortgroupType Distributed `
-FolderName $_.FolderName `
-LinuxUser $_.LinuxUser `
-SshPublicKey $publicKey `
-NetworkMode Static `
-IpAddressCidr $_.IpAddressCidr `
-Gateway $_.Gateway `
-DnsServers ($_.DnsServers -split ‘;’) `
-Cpu ([int]$_.Cpu) `
-MemoryGB ([int]$_.MemoryGB) `
-PowerOn
}
catch {
[pscustomobject]@{
VMName = $_.VMName
Status = ‘DeploymentFailed’
Error = $_.Exception.Message
}
}
}

$results | Format-Table -AutoSize

$results |
Export-Csv `
-Path ‘.linux-vm-deployment-results.csv’ `
-NoTypeInformation

A production batch process should also integrate:

IP address management.

DNS record creation.

Address conflict checks.

VM tags and ownership metadata.

Change-management records.

Deployment locking.

Concurrency limits.

Monitoring registration.

Backup policy assignment.

Automatic validation gates.

Validate the vSphere Configuration

Confirm that vSphere created the intended VM:

$vm = Get-VM -Name ‘ubuntu-app01’

$vm |
Select-Object `
Name,
PowerState,
NumCpu,
MemoryGB,
VMHost

Get-NetworkAdapter -VM $vm |
Select-Object `
Name,
Type,
NetworkName,
MacAddress,
ConnectionState

Inspect GuestInfo setting names and payload lengths without printing the complete encoded values:

Get-AdvancedSetting -Entity $vm |
Where-Object Name -like ‘guestinfo.*’ |
Sort-Object Name |
Select-Object Name, @{
Name = ‘ValueLength’
Expression = {
([string]$_.Value).Length
}
}

Expected settings include:

guestinfo.metadata
guestinfo.metadata.encoding
guestinfo.userdata
guestinfo.userdata.encoding

Do not print the complete user-data payload into shared logs or support tickets.

Validate cloud-init Inside the Guest

Connect with the injected SSH key and run:

cloud-id
sudo cloud-init status –long
sudo cloud-init schema –system –annotate
sudo cloud-init query ds
ip -br address
ip route
getent hosts “$(hostname)”
getent passwd vmadmin
sudo cat /etc/dtd-build-info
sudo ss -lntp | grep ‘:22’

A successful deployment should show:

cloud-init status is done.

The datasource contains VMware-related instance data.

The intended interface has the requested address.

The default route points to the requested gateway.

DNS resolution works.

The requested Linux user exists.

The deployment record contains the correct VM name and instance ID.

SSH is listening.

Ubuntu Validation

Inspect the rendered Netplan state:

sudo netplan get
sudo ls -la /etc/netplan
ip -br address
ip route

Rocky Linux Validation

Inspect NetworkManager:

sudo nmcli connection show
sudo nmcli device status
sudo ls -la /etc/NetworkManager/system-connections

Photon OS Validation

Inspect the active network stack and cloud-init overrides:

sudo networkctl status
sudo grep -R “config: disabled”
/etc/cloud/cloud.cfg
/etc/cloud/cloud.cfg.d

Troubleshoot GuestInfo and cloud-init

Troubleshooting is faster when transport, datasource detection, YAML validation, and operating-system rendering are treated as separate stages.

Symptom
Likely cause
Evidence to collect
Corrective action

VM boots but nothing is customized
Template was not cleaned, instance ID was reused, or VMware datasource was not detected
cloud-init status, datasource logs, /run/cloud-init
Clean the template, generate a unique instance ID, and confirm datasource support

User or SSH key is missing
Invalid YAML, missing cloud-config header, or incorrect encoding
schema validation and cloud-init logs
Correct the YAML and encoding declaration

DHCP address appears instead of the static IP
Stale guest network file, disabled cloud-init networking, or wrong MAC match
rendered network files, VM MAC, GuestInfo metadata
Remove stale state and verify MAC-based matching

VM has no network
Incorrect CIDR, gateway, VLAN, port group, or route
console, ip address, ip route, switch configuration
Correct the network data and upstream connectivity

Ubuntu retains template networking
A stale Netplan file conflicts with generated data
netplan get and /etc/netplan
Generalize or remove the hard-coded template file

Rocky creates no network profile
NetworkManager is disabled or rendering failed
nmcli, journal, and cloud-init logs
Enable NetworkManager and correct metadata

Photon ignores network metadata
A cloud-init override disables networking
files in /etc/cloud/cloud.cfg.d
Remove or revise the disabling override

Guest cannot read GuestInfo
open-vm-tools is missing, stopped, or incompatible
Tools version and vmtoolsd service state
Install or update open-vm-tools

Wait-Tools succeeds but SSH fails
VMware Tools loaded before cloud-init or SSH completed
cloud-init status, SSH service, network routes
Wait for cloud-init completion and test SSH separately

A second boot does not replay user data
cloud-init sees the same instance ID
instance cache and instance ID
Clean the test VM or deploy a new clone

Check Datasource Detection

Start with cloud-init’s datasource evidence:

sudo cloud-init status –long
sudo cat /run/cloud-init/ds-identify.log
sudo find /run/cloud-init -maxdepth 2 -type f -print
sudo cloud-init query ds

Check all cloud-init stages:

systemctl status cloud-init-local.service
systemctl status cloud-init.service
systemctl status cloud-config.service
systemctl status cloud-final.service

Inspect cloud-init Logs

The primary log files are:

/var/log/cloud-init.log
/var/log/cloud-init-output.log

Search for relevant failures:

sudo grep -iE
‘error|warn|vmware|guestinfo|network’
/var/log/cloud-init.log

sudo tail -n 200 /var/log/cloud-init-output.log

sudo journalctl
-u cloud-init-local
-u cloud-init
-u cloud-config
-u cloud-final

Validate User Data Before Deployment

Do not wait for a VM boot to discover a YAML error.

Save the generated user data to a file and validate it on a Linux system with cloud-init installed:

cloud-init schema
-c user-data.yaml
–annotate

Inside a deployed VM:

sudo cloud-init schema –system –annotate

Schema validation catches structural problems. It does not prove that a package exists, a service name is correct, or a gateway is reachable.

Test GuestInfo Transport Directly

Inside a guest with open-vm-tools, retrieve the encoding values:

vmware-rpctool
“info-get guestinfo.metadata.encoding”

vmware-rpctool
“info-get guestinfo.userdata.encoding”

When vmware-rpctool is unavailable, test through vmtoolsd:

vmtoolsd
–cmd “info-get guestinfo.metadata.encoding”

vmtoolsd
–cmd “info-get guestinfo.userdata.encoding”

Avoid printing the full user-data value into terminal transcripts or support systems.

Re-Test a Disposable Clone

A disposable lab VM can be reset with:

sudo cloud-init clean –logs –machine-id –seed
sudo reboot

This is not a routine production repair. Cleaning cloud-init can regenerate machine identity, rerun modules, alter networking, recreate users, and repeat commands. Use it only when the user-data operations are understood and console access is available.

Distribution Differences That Matter

Area
Ubuntu
Rocky Linux
Photon OS

Preferred starting point
Cloud image or generalized Server template
Generic Cloud image
Purpose-built cloud-init template or OVA

Common network stack
Netplan with networkd or NetworkManager
NetworkManager
Image and release dependent

SSH service
Commonly ssh.service
sshd.service
sshd.service

VMware Tools
open-vm-tools
open-vm-tools
open-vm-tools

Common template conflict
Stale Netplan file
Stale NetworkManager profile
Networking disabled in cloud-init

Datasource terminology
VMware
VMware
VMware or older VMwareGuestInfo terminology

Treat this as implementation guidance, not a universal support matrix. Validate the exact distribution, cloud-init package, open-vm-tools package, and template version used in production.

Security and Operational Considerations

GuestInfo Is Not a Secret Store

Base64 and gzip do not provide confidentiality. Avoid storing the following in GuestInfo:

Private SSH keys.

Long-lived API tokens.

Domain join passwords.

Repository credentials.

Database passwords.

Persistent cloud credentials.

Use GuestInfo for public SSH keys, non-sensitive bootstrap configuration, short-lived retrieval instructions, and references to an approved secrets-management service.

The redact option can reduce exposure after the VMware datasource reads selected values, but it does not transform GuestInfo into a secrets vault.

Keep Instance IDs Unique

Generate a new instance ID for every VM. A reused instance ID can cause cloud-init to treat a new clone as an existing instance.

Combining the VM name with a GUID provides both traceability and uniqueness.

Integrate IP Address Management

The script validates the syntax of an address. It does not prove ownership or availability.

A production pipeline should:

Reserve the address in IPAM.

Check that it is not already allocated.

Create or update DNS records.

Record the address owner.

Release the reservation when deployment fails before handoff.

Version Templates and Scripts Together

Record these items for each deployment:

Linux distribution and release.

Template version.

cloud-init version.

open-vm-tools version.

PowerCLI script version.

User-data schema version.

Network metadata profile version.

Versioning creates an evidence trail and prevents a script update from silently breaking older templates.

Use a Real Readiness Gate

A VM is not ready merely because vCenter reports PoweredOn.

A practical readiness gate should confirm:

VMware Tools is running.

cloud-init status is done.

The correct address is assigned.

The default route is correct.

DNS resolution works.

SSH key authentication succeeds.

The expected user and privilege policy exist.

Required first-boot files or services are present.

No cloud-init errors remain.

Monitoring and backup policies are assigned.

Ownership and support metadata are recorded.

Preserve Troubleshooting Evidence

The script leaves a failed VM in place. Define:

How long failed deployments remain.

Who owns investigation.

Which logs must be collected.

When the IP address is released.

When the VM can be deleted.

Whether the failed deployment must be attached to a change or incident record.

Automatic cleanup is safe only when the workflow can distinguish a failed deployment from a slow but recoverable first boot.

Conclusion

GuestInfo and cloud-init provide a clean bridge between vSphere infrastructure automation and Linux first-boot configuration. PowerCLI creates the VM, connects the intended network, reads the generated virtual NIC MAC address, and publishes declarative metadata before the guest starts. cloud-init then applies the hostname, static network, administrator account, SSH key, and deployment record through the Linux distribution’s native operating-system mechanisms.

The four GuestInfo advanced settings are the simplest part of the design. Reliability comes from template hygiene, unique instance identity, MAC-based network matching, correct encoding, controlled first power-on, and explicit guest validation.

Ubuntu, Rocky Linux, and Photon OS can use the same deployment pattern, but their templates are not interchangeable. Ubuntu commonly renders through Netplan, Rocky Linux through NetworkManager, and Photon OS requires close attention to datasource support and networking overrides.

Once the base workflow is stable, it can become a broader Linux provisioning service. IPAM, DNS, tagging, secrets retrieval, policy checks, CI/CD promotion, monitoring, backup, and post-build validation can all be added without changing the core contract: PowerCLI publishes first-boot intent through GuestInfo, and cloud-init applies it inside the guest.

External References

cloud-init: VMwareCanonical URL: https://docs.cloud-init.io/en/latest/reference/datasources/vmware.html

cloud-init: Network configurationCanonical URL: https://docs.cloud-init.io/en/latest/reference/network-config.html

cloud-init: How to validate user-data cloud configCanonical URL: https://docs.cloud-init.io/en/latest/howto/debug_user_data.html

cloud-init: How to debug cloud-initCanonical URL: https://docs.cloud-init.io/en/latest/howto/debugging.html

cloud-init: CLI commandsCanonical URL: https://docs.cloud-init.io/en/latest/reference/cli.html

Broadcom Knowledge Base: How does vSphere Guest OS Customization work with cloud-init to customize a Linux VMCanonical URL: https://knowledge.broadcom.com/external/article/311864/how-does-vsphere-guest-os-customization.html

Broadcom Developer Portal: New-VM Command | VMware PowerCLI ReferenceCanonical URL: https://developer.broadcom.com/powercli/latest/vmware.vimautomation.core/commands/new-vm

Broadcom Developer Portal: New-AdvancedSetting Command | VMware PowerCLI ReferenceCanonical URL: https://developer.broadcom.com/powercli/latest/vmware.vimautomation.core/commands/new-advancedsetting

Broadcom Developer Portal: Set-AdvancedSetting Command | VMware PowerCLI ReferenceCanonical URL: https://developer.broadcom.com/powercli/latest/vmware.vimautomation.core/commands/set-advancedsetting

Canonical: Ubuntu Cloud ImagesCanonical URL: https://cloud-images.ubuntu.com/

Rocky Linux Documentation: cloud-init fundamentalsCanonical URL: https://docs.rockylinux.org/10/guides/virtualization/cloud-init/01_fundamentals/

Rocky Linux Documentation: Advanced provisioningCanonical URL: https://docs.rockylinux.org/10/guides/virtualization/cloud-init/04_advanced_provisioning/

Photon OS Documentation: Cloud-initCanonical URL: https://vmware.github.io/photon/docs-v5/troubleshooting-guide/photon-os-general-troubleshooting/cloud-init/

VCF 9.1 API with Postman: Generate a Collection from OpenAPI and Automate Your First Workflow
TL;DR VMware Cloud Foundation 9.1 provides an OpenAPI definition for SDDC Manager that can be imported into Postman to generate a structured…

Next PostEnterprise MCP Authorization: IdP, RBAC, Workload Identity, and Zero-Touch OAuthTL;DR Enterprise MCP authorization is not solved by adding a login page to every MCP server. The scalable pattern is to make the enterprise identity provider the policy authority for…

The post Deploy Linux VMs on vSphere with cloud-init and GuestInfo: A Complete PowerCLI Guide appeared first on Digital Thought Disruption.