Getting started with Cloud-init on Hyper-V

Intro

This will go over setting up an environment to build Cloud-init nocloud data drives (ISO, not vfat), converting nocloud disk images to VHDX, and then using those to hands-off provision VMs on Hyper-V hosts that can then be configured with Ansible.

More specifically, this will demonstrate using Cloud-init and the Alma Linux genericcloud image to stand up an Alma Linux VM on a Server 2025 host.

What is Cloud-init?

Cloud-init is a Canonical project that’s become the defacto standard for touchless configuration of Linux VMs over the years. Cloud-init is a package that can be installed on a Linux or Unix system that will read configuration files and provision a template VM on first boot for its environment.

It’s a powerful tool, but, in this example, we’re going to use the bare minimum functionality - I just want a machine with a hostname and user account so I can run Ansible against it.

I would highly recommend you give the Cloud-init documentation a visit if you would like a deeper dive.

How is Cloud-init used?

In our case, we’ll be feeding a Cloud-init-ready installation of Linux a “nocloud” disk image in Joliet ISO format. This disk will contain YAML that Cloud-init will read and do.. stuff with.

Since we’re on Windows, we’ll have to jump through one or two hoops to get going - all the software you’d normally use to manipulate VHDs and ISOs isn’t quite so easy to come by when you’re not on Linux.

Why Hyper-V?

Because.

Where do I get Generic Cloud images that I can follow along with?

Dependencies

We’ll need some software to work with VHDXes and ISOs, as previously mentioned.

  1. qemu-img, a utility for working with disk images
  2. genisoimage, mkisofs, xorriso, or oscdimg - tools for working with ISO images

Installing Dependencies (Windows)

qemu-img alone can be downloaded from Cloudbase Solutions (company that supports cloudbase-init, which is basically cloud-init for Windows): https://cloudbase.it/qemu-img-windows/.

PowerShell:

(irm https://cloudbase.it/downloads/qemu-img-win-x64-2_3_0.zip -outfile qemu-img.zip) |
extract-archive .\qemu-img.zip

This will bring with it a few DLLs:

PS C:\Users\liam\projects\cloud-init-a9> gci qemu-img

    Directory: C:\Users\liam\projects\cloud-init-a9\qemu-img

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---           9/18/2013  1:35 PM          86528 libgcc_s_sjlj-1.dll
-a---           3/30/2015  3:09 PM        2584872 libglib-2.0-0.dll
-a---           3/30/2015  3:10 PM          79707 libgthread-2.0-0.dll
-a---           3/30/2015  3:38 AM        1475928 libiconv-2.dll
-a---           3/30/2015  1:59 PM         464017 libintl-8.dll
-a---           9/18/2013  1:35 PM          18944 libssp-0.dll
-a---           6/16/2015  9:37 PM        5615492 qemu-img.exe

More complete QEMU binaries for Windows, including qemu-img, can be found at: https://qemu.weilnetz.de/w64/

oscdimg is a part of the Windows Assessment and Deployment Kit, which can be installed with the winget package manager, if you have that installed (24H2+, incl. Server 2025 have Winget working by default).

Note that installing the ADK this way will not prompt you to select which components to install - and, by default, the ADK has a 2gb footprint, which is a bit much for just a tool to build Joliet format ISOs.

winget install Microsoft.WindowsADK

Alternatively, you can download and run the ADK installer directly from Microsoft. If you do it this way, you can select only the “Deployment Tools” feature and save some disk space, or download and run the oscdimg installer alone.

(irm https://go.microsoft.com/fwlink/?linkid=2196127 -method GET -outfile adksetup.exe) |
.\adksetup.exe
A screenshot of the Windows ADK installer with only the Deployment Tools package selected.

If you install the Deployment Tools, oscdimg will, by default, be at: C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe.

If you download the Deployment Tools, you won’t be prompted to select which features you want, but can then just run the oscdimg installer Oscdimg (DesktopEditions)-x86_en-us.msi.

If you downloaded the ADK to your Downloads directory, the path to the installer(s) including that for oscdimg would be ~\Downloads\ADK\Installers.

I have an archive containing oscdimg and its required .cab files. You could, too. I’m not going to distribute it here, though, because Microsoft might get mad (probably not.)

For reference, those files are:

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----         1/20/2025   8:15 PM          81130 52be7e8e9164388a9e6c24d01f6f1625.cab
-a----         1/20/2025   8:15 PM          80196 5d984200acbde182fd99cbfbe9bad133.cab
-a----         1/20/2025   8:15 PM          81299 9d2b092478d6cca70d5ac957368c00ba.cab
-a----         1/20/2025   8:16 PM          84314 bbf55224a0290f00676ddc410f004498.cab
-a----         1/19/2025  10:22 PM         417792 Oscdimg (DesktopEditions)-x86_en-us.msi

Finally, here’s a link to the Oscdimg help page at (on?) MS Learn.

Installing Dependencies (Linux)

I typically use WSL on my workstation, so I figured I’d include this as well. It’s a little easier to get going this way, but you might not want/be able to go the WSL route on a locked-down work laptop or Windows build box.

You can install both genisoimage and qemu-img on Debian 12 with:

# apt install genisoimage qemu-img

Both are in the default Debian 12 repositories.

On Enterprise Linux & friends (I use Alma), you’ll have to install EPEL first.

# dnf install epel-release
# dnf install genisoimage qemu-img

Converting a GenericCloud image to a VHDX

One command, once you’ve downloaded qemu-img somewhere.

C:\Users\liam\projects\a9-ci-ex> .\qemu-img\qemu-img.exe convert -O vhdx C:\Users\liam\Downloads\AlmaLinux-9-GenericCloud-9.2-20230513.x86_64.qcow2 .\AlmaLinux-9-GenericCloud-9.2-20230513.x86_64.vhdx

Creating cloud-init files

See the cloud-init documentation for more detail. I like to have a project directory set up as follows:

PS C:\Users\liam\projects\a9-ci-ex> gci

    Directory: C:\Users\liam\projects\a9-ci-ex

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----           1/18/2025  6:10 PM                cidata
-a---           1/18/2025  6:13 PM          55296 cloud-init.iso

PS C:\Users\liam\projects\cloud-init-a9> gci cidata

    Directory: C:\Users\liam\projects\cloud-init-a9\cidata

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---           1/18/2025  3:27 PM             51 meta-data
-a---           1/18/2025  3:28 PM             81 user-data

If you’re not using oscdimg, you don’t need the contents of your image to be in a specific directory - you can point genisoimage at individual files.

Here’s a sample meta-data file that provides an instance-id and hostname:

instance-id: $VMName
local-hostname: $VMName

Here’s a sample user-data file that creates a user, a password, and a ssh key:

#cloud-config
users:
- name: Karl
  groups: users,wheel
  passwd: octopus77
  shell: /bin/bash
  lock_passwd: False
  ssh_authorized_keys:
   - 'id-ed25519 aabbccdd'

ssh_pwauth: False

I don’t particularly care about more advanced configuration - I’d rather use Ansible for that.

Creating the Cloud-init userdata ISO

With genisoimage (WSL):

genisoimage -output cloud-init.iso -volid cidata -joliet -rock ./cidata/user-data ./cidata/meta-data

With oscdimg:

C:\Program` Files` `(x86`)\Windows` Kits\10\Assessment` and` Deployment` Kit\Deployment` Tools\amd64\Oscdimg\oscdimg.exe -j1 -lcidata -r .\cidata .\cloud-init.iso

Explanation of options:

-j1 : encode Joliet Unicode file names AND ISO 9660 file names
-l : label
-r : resolve symbolic links

Manually cloud-init a new VM with the Hyper-V MMC

Make a copy of the GenericCloud VHDX you created previously and intend to use. Save it somewhere on the storage you intend to boot the VM on. We’ll use this shortly.

Open up the Hyper-V MMC (Hyper-V Management console, virtmgmt.msc) on something that can connect to the server or cluster you want to use.

Right-click a server and select New > Virtual Machine.

In the New Virtual Machine Wizard dialog, select:

A name

Generation 2 (UEFI)

An amount of memory. Ballooning can be enabled or disabled.

Optionally, connect the VM to a vSwitch.

On the “Connect Virtual Hard Disk” page, select “Use an existing virtual hard disk.” Point this at the copy of your prepared GenericCloud or custom VM image.

In my example, I’ve dropped an Alma 9.5 VHDX in a D:\vhdx directory (in this case, D: lives on a Storage Spaces simple ReFS volume) that happens to be my default Hyper-V VM volume location, but this doesn’t matter one bit.

Click “Finish” on the final page. Do not immediately launch the VM - there’s a bit more prep work to do yet.

Back on the main Hyper-V Manager page, right-click the VM and select Settings.

Either disable Secure Boot or change the Secure Boot template to “Microsoft UEFI Certificate Authority.” Since this example isn’t a Windows VM, you’ll need to allow third-party UEFI certificates or disable Secure Boot to let GRUB boot the system.

Select a SCSI controller, and add a DVD drive to it.

Point said DVD drive at your cloud-init ISO. In my case, I’ve already dropped this in my user’s Downloads directory on the server.

Click OK when you’re done.

Double-click the VM to bring up the remote console. Click Start.

Wait for the VM to boot. If the hostname is set, great! That means cloud-init worked.

Manually cloud-init a new VM with Windows Admin Center

Make a copy of the GenericCloud VHDX you created previously and intend to use. Save it somewhere on the storage you intend to boot the VM on. We’ll use this shortly.

Log on to Windows Admin Center. Access the server or cluster you want to create the VM on.

Open the Virtual Machines snap-in.

Under the landing page (VM inventory), click Add > New.

Configure processors, generation, networking to suit.

When you get to the Storage configuration, select Add…

…then choose the VHDX you prepared earlier.

Click Create.

Wait for the VM to be created. The WAC VM plugin will update shortly.

Select the VM, then click Settings.

Select Disks, then click Add Disk.

Select “Use an use an existing virtual hard disk or ISO image file,” then enter or browse to the path of your cloud-init data ISO. Click “Save disks settings.”

Once disk settings have been saved, enter the Security menu for the VM.

Disable Secure Boot or change the Secure Boot template to “Microsoft UEFI Certificate Authority.” Click “Save security settings,” then click Close.

Select the VM again, then expand the Power menu and click Start.

You may need to refresh the page to see the state change. Do so if it does not refresh itself, then select Connect and click the Connect option.

Enter your credentials when prompted.

You should now see a basic console with your chosen instance name. If you configured networking and/or ssh keys or users, you should be able to use them.

Scripting the unattended creation of a Hyper-V Alma 9 VM

The previous two methods are all well and good, but why would we go through all this effort to still need to click on things manually? That would be silly! And we’re not silly! We’re sysadmins, or, at the very least, we’re pretending to be sysadmins!

So let’s make things that don’t require clicks! Terraform method coming Soon(TM).

Say I want to tell my computer what name, switch, VHD, username and public key I want it to use to give me a new VM:

$ManicPgSQLParams = @{
	VMName = 'manictime-pgsql'
	VMSwitchName = 'ext-untagged'
	OriginalVHDXPath = 'D:\ISOs\AlmaLinux-9-GenericCloud-122024.vhdx'
	Username = 'liam'
	PublicKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC7QvzfPqlU1OKcyF8FRVoPNdSl+dJWcePUWM/Rn2+nZ'
	Force = $true
}

Let’s write a script!

This will:

Once this is done, assuming you have DHCP leases being registered, you can SSH in (or immediately kick off some Ansible!)

Pop over to this script’s associated Github repository for more detailed usage instructions.

Function CloudInit-VM {
	param (
		[string]$VMName = 'my-vm',
		[string]$VMSwitchName = 'untagged',
		[string]$OriginalVHDXPath = 'D:\ISOs\AlmaLinux-9-GenericCloud-122024.vhdx',
		[string]$CloudInitMetadataPath = 'C:\tmp\cloud-init',
		[string]$CloudInitMetadataOutPath = 'C:\tmp\cloud-init.iso',
        [string]$Username = 'cloud-user',
        [string]$PublicKey,
        #[securestring]$HashedPassword = 'aabbccddeeff',
		[int]$vCPUs = 8,
		[long]$Memory = 8GB,
        [bool]$Force = $false
	)

	Function Set-MetadataFile {
		param (
			[string]$ParentPath,
			[string]$Content,
			[string]$MetadataType,
            [bool]$Force
		)

        if (-not (Test-Path -Path $ParentPath)) {
            New-Item `
                -ItemType Directory `
                -Path $ParentPath
        }

        Write-Host -Object `
            "Generating $($MetadataType)-data file for cloud-init at $($ParentPath)\$($MetadataType)-data."

		if ( (-not ( Test-Path -Path "$($ParentPath)\$($MetadataType)-data" )
            ) -or $Force) {
	
            $MetadataFile = @{
                Path = "$($CloudInitMetadataPath)\$($MetadataType)-data"
                Value = $Content
            }
            
            Set-Content @MetadataFile 
		
		} else {
		
			Write-Error -Message `
				"$($MetadataType)-data file in $($Path) already exists and -Force is not set. Exiting."
				
			return 1
			
		}

	}

	# populate the meta-data file

    $MetadataFile = @{
        ParentPath = $CloudInitMetadataPath
        Content = @"
instance-id: $VMName
local-hostname: $VMName
"@
        MetadataType = 'meta'
        Force = $Force
    }

    # populate the user-data file with ssh public key and username

    Set-MetadataFile @MetadataFile

    # if you want to pass a password, generate it with mkpasswd, and add, under your user:
    # hashed_passwd: $(ConvertFrom-SecureString -SecureString $HashedPassword -AsPlainText)
    # and set lock_passwd to False
    $UserdataFile = @{
        ParentPath = $CloudInitMetadataPath
        Content = @"
#cloud-config
users:
- name: $Username
  groups: users,wheel
  sudo: ALL=(ALL) NOPASSWD:ALL
  shell: /bin/bash
  lock_passwd: True
  ssh_authorized_keys:
   - $PublicKey

ssh_pwauth: False
"@
        MetadataType = 'user'
        Force = $Force
    }

    Set-MetadataFile @UserdataFile

    # convert metadata files to a Joliet cloud-init ISO
	
	& C:\Program` Files` `(x86`)\Windows` Kits\10\Assessment` and` Deployment` Kit\Deployment` Tools\amd64\Oscdimg\oscdimg.exe -j1 -lcidata -r $CloudInitMetadataPath $CloudInitMetadataOutPath
	
	# make a copy of the base vhdx - put it under the configured default vhdx path on the host

	$NewVHDXPath = "$((Get-VMHost).VirtualHardDiskPath)\$($VMName).vhdx"
	
	Write-Host -Object `
		"Checking for existence of path $($NewVHDXPath)."
		
	if (Test-Path -Path $NewVHDXPath) {
		# if the -Force flag IS NOT set, do not overwrite the existing VHDX and terminate.
		if (-not $Force) {
		
			Write-Error -Message `
				'Default new VHDX path is already occupied and -Force is not set. Exiting.'
			return 1
			
		}
		
		# if the -Force flag IS set, attempt to remove the existing VHDX.
		# This WILL NOT SUCCEED if a VM is using said VHDX.
		
		if ($Force) {
		
			Write-Host -Object `
				"-Force is set. Attempting to remove VHDX at $($NewVHDXPath)."
				
			try {
			
				Remove-Item `
					-Path $NewVHDXPath `
					-Force `
					-Confirm:$false `
					-ErrorAction 'Stop'
					
			} catch {
			
				Write-Error -Message @"
-Force is set, but failed to remove existing file at desired clone VHDX path $($NewVHDXPath).
$($_)
Terminating.
"@

                return 1

			}
		}
	}

    # copy the VM template VHDX for our new VM.

	Write-Host -Object `
		"Copying template VHDX from $($OriginalVHDXPath) to $($NewVHDXPath)."

	try {

		Copy-Item -Path $OriginalVHDXPath -Destination $NewVHDXPath

	} catch {

		Write-Error -Message `
			"Copy failed with error: $($_). Terminating."

        return 1

	}

	Write-Host -Object @"
Attempting to create VM $($VMName) with specifications:

Threads: $($vCPUs)
Memory (Mb): $($Memory/([Math]::Pow(2,20)))
Switch: $($VMSwitchName)

"@

	try {
		# create the VM
		$VMParams = @{
			Name = $VMName
			SwitchName = $VMSwitchName
			Generation = 2
			MemoryStartupBytes = "$($Memory)" # is there a reason for this?
			ErrorAction = 'Stop'
		}
		
		$VM = New-VM @VMParams
	} catch {

		Write-Error -Message @"
Failed to create VM $($VMName) with error:
$($_)
Terminating script.
"@
		return 1

	}

    # assign vCPUs
    Set-VMProcessor -VM $VM -Count $vCPUs
	
	# add the copy of the VHDX to the VM
    
    Write-Host -Object `
        "Assigning clone VHDX $($NewVHDXPath) to VM $($VMName)."

    try {

        $VMDiskParams = @{
            VM = $VM
            ControllerType = 'SCSI'
            ControllerNumber = 0
            ControllerLocation = 0
            Path = $NewVHDXPath
            ErrorAction = 'Stop'
        }
        
        Add-VMHardDiskDrive @VMDiskParams

    } catch {

        Write-Error -Message `
            "Assigning VHDX $($NewVHDXPath) to VM $($VMName) failed with error: $($_). Terminating."

        return 1

    }

	# configure Secure Boot to allow non-MS signatures
	# you can also set by GUID 272e7447-90a4-4563-a4b9-8e4ab00526ce

    Write-Host -Object `
        "Setting $($VMName)'s UEFI to allow third-party Secure Boot signatures."

	Set-VMFirmware -VM $VM -SecureBootTemplate 'MicrosoftUEFICertificateAuthority'
	
	# add the cloud-init metadata image to the VM

    Write-Host -Object `
        "Assigning the $($CloudInitMetadataOutPath) metadata image to VM $($VMName)."
    
    try {

        $VMDvdParams = @{
            VM = $VM
            ControllerNumber = 0
            ControllerLocation = 1
            Path = $CloudInitMetadataOutPath
            ErrorAction = 'Stop'
        }
        
        Add-VMDvdDrive @VMDvdParams

    } catch {

        Write-Error -Message `
            "Failed to assign $($CloudInitMetadataOutPath) to VM $($VMName) with error: $($_). Terminating."

        return 1

    }

    # make sure first boot device is the cloned VHDX

    Write-Host -Object `
        "Setting VM $($VMName)'s first boot device to its hard drive (cloned VHDX.)"

	Set-VMFirmware -VM $VM -FirstBootDevice (Get-VMHardDiskDrive -VM $VM)
	
	# start the VM

    Write-Host -Object `
        "Starting VM $($VMName)."

	try {

        $StartVM = @{
            VM = $VM
            ErrorAction = 'Stop'
        }
    
        Start-VM @StartVM

    } catch {

        Write-Error -Message `
            "Failed to start VM $($VMName) with error: $($_). Terminating."

        return 1

    }

    # wait for Cloud-init
    Write-Host -Object `
        'Waiting 30 seconds for cloud-init.'

	Start-Sleep(30)
	
	# remove the Cloud-init disk

    Write-Host -Object `
        "Removing cloud-init drive from VM $($VMName)."
        
	Remove-VMDVDDrive -VMName $VM.Name -ControllerNumber 0 -ControllerLocation 1

    Write-Host -Object @"

Successfully created VM $($VMName) with:

Threads: $($vCPUs)
Memory (Mb): $($Memory/([Math]::Pow(2,20)))
Switch: $($VMSwitchName)

Original VHDX: $($OriginalVHDXPath)
Cloned VHDX: $($NewVHDXPath)

Generated cloud-init ISO: $($CloudInitMetadataOutPath)

"@

    Return 0
}