PowerShell: Automatically Deploy an Azure Test or Development Environment as Code (IaC)

We often need to build testing, packaging or development environments in Azure. Often these are built in an ad-hoc way, manually. This is fine, if a little time consuming, but Azure makes it easy to create scripts that will automate the provisioning of these environments. In fact, automation should really be the preferred method of deployment as it ensures that environments can be deployed or re-deployed rapidly whilst maintaining build quality.

The script that I am going to describe here uses PowerShell Azure cmdlets to provide a very flexible method of deploying the Azure environment, and deploying as many fully configured VMs (desktops or servers) as you may need.

This script relies on the AZ PowerShell module, and this should be installed on any workstation prior to running the script by running the following command. The script uses functions, for code minimisation and to simplify code reuse.

Install-Module -Name Az

Azure Environment and Networking

All the variables required for the script are set at the beginning, and I have added comments in the script describing all of these. All output is sent to the console.

The ConfigureNetwork function creates a Virtual Network, a subnet and a Network Security Group (NSG) name. The NSG also has a rule enabled for RDP. You can add other rules if you need them. If you don’t need the networking configured simply comment out the ConfigureNetwork call later in the script.

function ConfigureNetwork {
    $virtualNetwork = New-AzVirtualNetwork -ResourceGroupName $RGName -Location $Location -Name $VNet -AddressPrefix
    $subnetConfig = Add-AzVirtualNetworkSubnetConfig -Name default -AddressPrefix -VirtualNetwork $virtualNetwork
    $rule1 = New-AzNetworkSecurityRuleConfig -Name rdp-rule -Description "Allow RDP" -Access Allow -Protocol Tcp -Direction Inbound -Priority 100 -SourceAddressPrefix * -SourcePortRange * -DestinationAddressPrefix * -DestinationPortRange 3389
    $nsg = New-AzNetworkSecurityGroup -ResourceGroupName $RGName -Location $location -Name $NsgName -SecurityRules $rule1
        # $rule1, $rule2 etc
        If ($nsg.ProvisioningState -eq "Succeeded") {Write-Host "Network Security Group created successfully"}Else{Write-Host "*** Unable to create or configure Network Security Group! ***"}
    $Vnsc = Set-AzVirtualNetworkSubnetConfig -Name default -VirtualNetwork $virtualNetwork -AddressPrefix "" -NetworkSecurityGroup $nsg
    $virtualNetwork | Set-AzVirtualNetwork >> null
        If ($virtualNetwork.ProvisioningState -eq "Succeeded") {Write-Host "Virtual Network created and associated with the Network Security Group successfully"}Else{Write-Host "*** Unable to create the Virtual Network, or associate it to the Network Security Group! ***"}

I wanted the script to support fully configured VMs, which can be configured using scripts and Azure’s Custom Script Extension functionality. This enables scripts to be run during, or after VM deployment. These scripts need storing somewhere, so I have included a CreateStorageAccount function.

function CreateStorageAccount {
    If ($StorAccRequired -eq $True)
        $storageAccount = New-AzStorageAccount -ResourceGroupName $RGName -AccountName $StorAcc -Location uksouth -SkuName Standard_LRS
        $ctx = $storageAccount.Context
        $Container = New-AzStorageContainer -Name $ContainerName -Context $ctx -Permission Container
        If ($storageAccount.StorageAccountName -eq $StorAcc -and $Container.Name -eq $ContainerName) {Write-Host "Storage Account and container created successfully"}Else{Write-Host "*** Unable to create the Storage Account or container! ***"}    
        #$BlobUpload = Set-AzStorageBlobContent -File $BlobFilePath -Container $ContainerName -Blob $Blob -Context $ctx 
        Get-ChildItem -Path $ContainerScripts -File -Recurse | Set-AzStorageBlobContent -Container "data" -Context $ctx
            Write-Host "Creation of Storage Account and Storage Container not required"

This function creates a Storage Account and a Container (for holding the scripts). It then copies all files found in the $ContainerScripts variable up to the Container. Again, if this functionality is not required (e.g. you are storing your scripts in GitHub) you can comment out the CreateStorageAccount call later in the script.

Creating the VM/s

To provide the correct details for the image, the available SKUs need to be listed. This is done with the following command (don’t forget to enter the location that you want to use when creating the resource, as availability varies between regions).

Get-AzVMImageSku -Location "uksouth" -PublisherName "MicrosoftWindowsServer" -Offer "WindowsServer"

To identify available Windows 10 SKUs, use this command.

Get-AzVMImageSku -Location "uksouth" -PublisherName "MicrosoftWindowsDesktop" -Offer "Windows-10"

From this data we can put together the image name to be used for the VM. For example, We want to deploy the latest 20h2 Windows 10 image, so we would use the following string for the ImageName parameter.


The CreateVMp function creates the VM, sets the local Admin User and Password, and creates a Public IP address.

function CreateVMp($VMName) {
    $PublicIpAddressName = $VMName + "-ip"

    $Params = @{
    ResourceGroupName = $RGName
    Name = $VMName
    Size = $VmSize
    Location = $Location
    VirtualNetworkName = $VNet
    SubnetName = "default"
    SecurityGroupName = $NsgName
    PublicIpAddressName = $PublicIpAddressName
    ImageName = $VmImage
    Credential = $VMCred

    $VMCreate = New-AzVm @Params
    If ($VMCreate.ProvisioningState -eq "Succeeded") {Write-Host "Virtual Machine $VMName created successfully"}Else{Write-Host "*** Unable to create Virtual Machine $VMName! ***"}

Configuring the VM/s

Configuring VMs is done by means of a script, run on the newly provisioned VM. Obviously, a script allows you to do anything that you want. In my example the script is run from the Storage Account but copies media from a GitHub location and then installs Orca. You can use the same technique to do pretty much anything that you need to.

The script is run using the Custom Script Extension functionality in Azure. The function, RunVMConfig, can be run against any Azure Windows VM, it does not have to have been created using this script.

function RunVMConfig($VMName, $BlobFilePath, $Blob) {

    $Params = @{
    ResourceGroupName = $RGName
    VMName = $VMName
    Location = $Location
    FileUri = $BlobFilePath
    Run = $Blob
    Name = "ConfigureVM"

    $VMConfigure = Set-AzVMCustomScriptExtension @Params
    If ($VMConfigure.IsSuccessStatusCode -eq $True) {Write-Host "Virtual Machine $VMName configured successfully"}Else{Write-Host "*** Unable to configure Virtual Machine $VMName! ***"}

The script that will be run is taken from the Storage Account Container, but it will also accept the RAW path to a GitHub repository. You would then not need to create the Storage Account.

Script Main Body

The main body of the script is actually quite short, due to the use of functions. The Resource Group is created, and then the ConfigureNetwork and CreateStorageContainer functions are called (if required, they can be commented out).

# Main Script

# Create Resource Group
$RG = New-AzResourceGroup -Name $RGName -Location $Location
If ($RG.ResourceGroupName -eq $RGName) {Write-Host "Resource Group created successfully"}Else{Write-Host "*** Unable to create Resource Group! ***"}

# Create VNet, NSG and rules (Comment out if not required)

# Create Storage Account and copy media (Comment out if not required)

# Build VM/s
$Count = 1
While ($Count -le $NumberOfVMs)
    Write-Host "Creating and configuring $Count of $NumberofVMs VMs"
    $VM = $VmNamePrefix + $VmNumberStart
    CreateVMp "$VM"
    RunVMConfig "$VM" "https://packagingstoracc.blob.core.windows.net/data/VMConfig.ps1" "VMConfig.ps1"
    # Shutdown VM if $VmShutdown is true
    If ($VmShutdown)
        $Stopvm = Stop-AzVM -ResourceGroupName $RGName -Name $VM -Force
        If ($RG.ResourceGroupName -eq $RGName) {Write-Host "VM $VM shutdown successfully"}Else{Write-Host "*** Unable to shutdown VM $VM! ***"}

In order to provision multiple VMs, a While loop is used, to rerun code until the required number of VMs are provisioned. The VM name is created by concatenating variables, and the name is passed, first to the CreateVMp function, and then to the RunVMConfig function. A variable at the beginning of the script is used to check if the newly provisioned VM should be shutdown or not. This is very useful as an aid to manage costs.

The full script is located here.


The VM config script I used is written to output into the Windows Event Log for ease of fault finding. It is located here.


The script outputs to the console and successful completion shouls look something like this.

Resource Group created successfully
Network Security Group created successfully
Virtual Network created and associated with the Network Security Group successfully
Storage Account and container created successfully
Account              SubscriptionName          TenantId                             Environment
-------              ----------------          --------                             -----------
graham@*********.*** Microsoft Partner Network ********-****-****-****-********73ab AzureCloud 

ICloudBlob                         : Microsoft.Azure.Storage.Blob.CloudBlockBlob
BlobType                           : BlockBlob
Length                             : 2034
IsDeleted                          : False
BlobClient                         : Azure.Storage.Blobs.BlobClient
BlobProperties                     : Azure.Storage.Blobs.Models.BlobProperties
RemainingDaysBeforePermanentDelete : 
ContentType                        : application/octet-stream
LastModified                       : 11/30/2020 10:03:49 AM +00:00
SnapshotTime                       : 
ContinuationToken                  : 
Context                            : Microsoft.WindowsAzure.Commands.Storage.AzureStorageContext
Name                               : VMConfig.ps1

Creating and configuring 1 of 1 VMs
Virtual Machine PVMMSI500 created successfully
Virtual Machine PVMMSI500 configured successfully
VM PVMMSI500 shutdown successfully

Logging to the Windows Eventlog using Powershell

When automating tasks, such as software installation, using Powershell, it is useful to output to a logfile for fault finding purposes, but I have never liked the idea of creating log files, as these are not easily managed and can build up over time. Another problem is that no-one, other than yourself, is likely to know that the logs exist, or where it is located. Everyone, however, knows about the Windows Eventlog and how to access it, so it makes sense to log your information there.

Adding script output to the Windows Eventlog is reasonably straightforward, but has to be done in the correct way. To use the Eventlog you need to create two items, a new Eventlog and a Source for the log entries. Optionally you can then configure the log that you have created. The code for this is below.

New-EventLog -LogName Application -Source "Documentum_Webtop-AddIn_1.0_x86_R01"
Limit-EventLog -OverflowAction OverWriteAsNeeded -MaximumSize 64KB -LogName Application

The first line of code creates a log called Application, with a source called Documentum_Webtop-AddIn_1.0_x86_R01. The source is simply the packaged application that I am going to be installing. Using this name makes it easy to identify log entries from the application within the Eventlog.

The second line of code sets the maximum size of the log to 64Kb and configures the log to overwrite when it gets to the maximum size.

I can now write to the Eventlog by using the following command.

Write-EventLog -LogName "Application" -Source "Documentum_Webtop-AddIn_1.0_x86_R01" -EventID 25001  -EntryType Information -Message "Begining install of Application Documentum_Webtop-AddIn_1.0_x86_R01"

The EventID should be 25000 or greater, as this range is undocumented.

ErrorType can be Information, Warning or Error.

When writing install and uninstall scripts, I like to include the New-Eventlog and Limit-EventLog entries at the beginning of the Install script, and then remove the Source at the end of the Uninstall script. This keeps things tidy, especially if you have a large number of applications to install.

To remove the source use the following command.

Remove-EventLog -Source "Documentum_Webtop-AddIn_1.0_x86_R01"

Add a User or Computer to multiple groups using PowerShell

During many deployment or transformation projects, no matter how organised, there is often a requirement to add Users or Computers to application deployment groups during the rollout phase. Often this has to be done at the last minute due to unexpected requirements (or users simply not having responded to requiests for information.

I wrote the following Powershell script to allow Users or Computers to be added to multiple groups. All the groups are expected to be in one OU, and this is set when the script is first run. It will then use that location when opened subsequently. Multiple groups can be selected.

This script requires that the Microsoft RSAT tools are installed on the machine where the script is run.

# This application lists all available software groups and allows multiple groups to
# be added to a Computer or User
# Created by Graham Higginson 13/12/2017
# V1.0

#region  ScriptForm  Designer

#region  Constructor



#region Post-Constructor Custom Code


#region Form Creation
#Warning: It is recommended that changes inside this region be handled using the ScriptForm Designer.
#When working with the ScriptForm designer this region and any changes within may be overwritten.
#~~< Form1 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$Form1 = New-Object System.Windows.Forms.Form
$Form1.ClientSize = New-Object System.Drawing.Size(904, 704)
#$Form.AutoScroll = $true
#~~< Label4 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$Label4 = New-Object System.Windows.Forms.Label
$Label4.Location = New-Object System.Drawing.Point(24, 642)
$Label4.Size = New-Object System.Drawing.Size(604, 15)
$Label4.TabIndex = 10
$Label4.Text = "Enter AD group location here, e.g. OU=App Groups, OU=Groups, OU=Client, DC=Company, DC=Local "
#~~< TextBox3 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$TextBox3 = New-Object System.Windows.Forms.TextBox
$TextBox3.Location = New-Object System.Drawing.Point(24, 660)
$TextBox3.Size = New-Object System.Drawing.Size(540, 21)
$TextBox3.TabIndex = 9
$TextBox3.Text = ""
#~~< TextBox2 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$TextBox2 = New-Object System.Windows.Forms.TextBox
$TextBox2.Location = New-Object System.Drawing.Point(24, 475)
$TextBox2.Multiline = $true
$TextBox2.ScrollBars = "Vertical"
$TextBox2.WordWrap = $false
$TextBox2.Size = New-Object System.Drawing.Size(540, 141)
$TextBox2.TabIndex = 8
$TextBox2.Text = ""
#~~< Label3 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$Label3 = New-Object System.Windows.Forms.Label
$Label3.Font = New-Object System.Drawing.Font("Tahoma", 12.0, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Point, ([System.Byte](0)))
$Label3.Location = New-Object System.Drawing.Point(114, 479)
$Label3.Size = New-Object System.Drawing.Size(277, 23)
$Label3.TabIndex = 8
$Label3.Text = ""
$Label3.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter
#~~< ListView1 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$ListView1 = New-Object System.Windows.Forms.ListView
$ListView1.Location = New-Object System.Drawing.Point(24, 81)
$ListView1.Size = New-Object System.Drawing.Size(540, 374)
$ListView1.TabIndex = 7
#$ListView1.CheckBoxes = $true
$ListView1.FullRowSelect = $true
$ListView1.Text = "ListView1"
$ListView1.UseCompatibleStateImageBehavior = $false
$ListView1.MultiSelect = $true
$ListView1.View = [System.Windows.Forms.View]::Details
#~~< ColumnHeader1 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$ColumnHeader1 = New-Object System.Windows.Forms.ColumnHeader
$ColumnHeader1.Text = "Group Name"
$ColumnHeader1.Width = 400
#~~< ColumnHeader2 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#$ColumnHeader2 = New-Object System.Windows.Forms.ColumnHeader
#$ColumnHeader2.Text = "Install State"
#$ColumnHeader2.Width = 100
#~~< Button4 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$Button4 = New-Object System.Windows.Forms.Button
$Button4.Font = New-Object System.Drawing.Font("Tahoma", 12.0, [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Point, ([System.Byte](0)))
$Button4.Location = New-Object System.Drawing.Point(678, 408)
$Button4.Size = New-Object System.Drawing.Size(120, 47)
$Button4.TabIndex = 6
$Button4.Text = "Close"
$Button4.UseVisualStyleBackColor = $true
#~~< Button3 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$Button3 = New-Object System.Windows.Forms.Button
$Button3.Font = New-Object System.Drawing.Font("Tahoma", 12.0, [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Point, ([System.Byte](0)))
$Button3.Location = New-Object System.Drawing.Point(678, 341)
$Button3.Size = New-Object System.Drawing.Size(120, 47)
$Button3.TabIndex = 5
$Button3.Text = "Clear"
$Button3.UseVisualStyleBackColor = $true
#~~< Button2 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$Button2 = New-Object System.Windows.Forms.Button
$Button2.Font = New-Object System.Drawing.Font("Tahoma", 12.0, [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Point, ([System.Byte](0)))
$Button2.Location = New-Object System.Drawing.Point(678, 642)
$Button2.Size = New-Object System.Drawing.Size(120, 47)
$Button2.TabIndex = 4
$Button2.Text = "Update"
$Button2.UseVisualStyleBackColor = $true
#~~< Button1 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$Button1 = New-Object System.Windows.Forms.Button
$Button1.Font = New-Object System.Drawing.Font("Tahoma", 12.0, [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Point, ([System.Byte](0)))
$Button1.Location = New-Object System.Drawing.Point(678, 196)
$Button1.Size = New-Object System.Drawing.Size(120, 47)
$Button1.TabIndex = 3
$Button1.Text = "Add"
$Button1.UseVisualStyleBackColor = $true
#~~< Label2 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$Label2 = New-Object System.Windows.Forms.Label
$Label2.Font = New-Object System.Drawing.Font("Tahoma", 9.5, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Point, ([System.Byte](0)))
$Label2.Location = New-Object System.Drawing.Point(663, 81)
$Label2.Size = New-Object System.Drawing.Size(201, 19)
$Label2.TabIndex = 2
$Label2.Text = "Enter computer or User name"
#~~< Label1 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$Label1 = New-Object System.Windows.Forms.Label
$Label1.Font = New-Object System.Drawing.Font("Tahoma", 16.0, [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Point, ([System.Byte](0)))
$Label1.Location = New-Object System.Drawing.Point(214, 9)
$Label1.Size = New-Object System.Drawing.Size(446, 32)
$Label1.TabIndex = 1
$Label1.Text = "Multi-group add Tool"
#~~< TextBox1 >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$TextBox1 = New-Object System.Windows.Forms.TextBox
$TextBox1.Location = New-Object System.Drawing.Point(663, 103)
$TextBox1.Size = New-Object System.Drawing.Size(135, 20)
$TextBox1.TabIndex = 0
$TextBox1.Text = ""


#region Custom Code


#region Event Loop

function Main{
    #$Form1.AcceptButton = $Button1
    foreach ($Group in $Groups)
           $Line = New-Object System.Windows.Forms.ListViewItem($Group.Name)





#region Event Handlers

function Button1MouseClick( $object )
$CompName = "" 
$SelectedGroups = $listView1.SelectedItems.Text 

Try {$Name = Get-ADComputer $TextBox1.Text}
    Catch {}

Try {if ($Error[0] -ne "") {$Name = Get-ADUser $TextBox1.Text -EA}}
    Catch {}


if ($Name -ne "")

   foreach ($SelectedGroup in $SelectedGroups)
        #Write-Host $SelectedGroup 
        $Members = Get-ADGroupMember -identity $SelectedGroup
        $Identity = get-ADGroup $SelectedGroup
        if ($Members.name -notcontains $TextBox1.Text -and $Members.SamAccountName -notcontains $TextBox1.Text)
            $Result = Add-ADGroupMember -identity $Identity.DistinguishedName -members $Name.DistinguishedName 
            if ($error[0] -gt "")
                    $TextBox2.AppendText("Unable to add " + $TextBox1.Text + " to " + $SelectedGroup + "`r`n")
                    $TextBox2.AppendText("Error message " + $Error[0] + "`r`n")
                    $TextBox2.AppendText("Successfully added " + $TextBox1.Text + " to " + $SelectedGroup + "`r`n")
    $TextBox2.AppendText($TextBox1.Text + " is already a member of " + $SelectedGroup + "`r`n")


   $TextBox2.AppendText("Unable to find device " + $Name + "!!!`r`n")


function Button2MouseClick($object)
    Set-ItemProperty -path HKCU:\Software\MultiGroupAddTool -Name SearchBase -Value $TextBox3.text
    $SearchBase= $TextBox3.Text 
    Try {$Groups = get-adgroup -filter * -searchbase $SearchBase | sort Name}
                $TextBox2.AppendText("Unable to get AD group list" + "`r`n")
                $TextBox2.AppendText("Error message " + $Error[0] + "`r`n")
    foreach ($Group in $Groups)
           $Line = New-Object System.Windows.Forms.ListViewItem($Group.Name)


function Button3MouseClick($object)
    $Label3.Text = ""		

function Button4MouseClick($object)
   $Groups = ""

function Label1Click( $object ){


    #$TextBox2.AppendText("Preparing AD group list..." + "`r`n")

    if ((Test-Path -Path HKCU:\Software\MultiGroupAddTool) -eq $false)
        New-Item -path HKCU:\Software\MultiGroupAddTool
        New-ItemProperty -path HKCU:\Software\MultiGroupAddTool -Name SearchBase
        $SearchBase = ""
                $Reg = Get-ItemProperty -path HKCU:\Software\MultiGroupAddTool -Name SearchBase
                $TextBox3.text = $Reg.SearchBase
                $SearchBase = $Reg.SearchBase

    if ($SearchBase -eq "") 
        [System.Windows.MessageBox]::Show("Please add AD group location")

    # **** Change the -searchbase entry below to point at the Active Directory OU containing Application Deployment Groups ****
    #$Groups = get-adgroup -filter * -searchbase "OU=Application Catalogue, OU=Software, OU=Groups, OU=Client, DC=Arqiva, DC=Local" | sort Name
    if ($SearchBase -ne "")
    $Groups = get-adgroup -filter * -searchbase $SearchBase | sort Name
        if ($error[0] -gt "")
                $TextBox2.AppendText("Unable to get AD group list" + "`r`n")
                $TextBox2.AppendText("Error message " + $Error[0] + "`r`n")
                #$TextBox2.AppendText("Successfully added " + $CompName + " to " + $SelectedGroup + "`r`n") 

Main #This call must remain below all other event functions