Following on from my previous post on creating an AVD environment, for those who want to deploy AVD, it’s recommended to build an Azure landing zone first (plus if going for the Microsoft Specialty, a scripted landing zone and AVD deployment is always useful)
Again, using a combination of Bicep, powershell and POSHGUI makes the deployment much easier
UPDATE: Now on Powershell Gallery
Install-Script -Name BuildAZLandingZone
As usual, all scripts are on Github to save having to copy and paste from this post:
GUI
Bicep Scripts
Let’s start with the powershell script which is fairly straight forward. First job is to check that Bicep and the AZ modules are installed:
#Install Bicep
if((Test-Path "$env:USERPROFILE\.bicep") -eq $false) {
# Create the install folder
$installPath = "$env:USERPROFILE\.bicep"
$installDir = New-Item -ItemType Directory -Path $installPath -Force
$installDir.Attributes += 'Hidden'
# Fetch the latest Bicep CLI binary
(New-Object Net.WebClient).DownloadFile("https://github.com/Azure/bicep/releases/latest/download/bicep-win-x64.exe", "$installPath\bicep.exe")
# Add bicep to your PATH
$currentPath = (Get-Item -path "HKCU:\Environment" ).GetValue('Path', '', 'DoNotExpandEnvironmentNames')
if (-not $currentPath.Contains("%USERPROFILE%\.bicep")) { setx PATH ($currentPath + ";%USERPROFILE%\.bicep") }
if (-not $env:path.Contains($installPath)) { $env:path += ";$installPath" }
}
Write-Host "Installing AZ modules if required (current user scope)"
#Install AZ Module if not available
if (Get-Module -ListAvailable -Name Az*) {
Write-Host "AZ Module Already Installed"
}
else {
try {
Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force
}
catch [Exception] {
$_.message
exit
}
}
Now, we create a quick and dirty temp folder so we can download the files and then bin at the end of the deployment:
#Create Temp Folder
$random = Get-Random -Maximum 1000
$random = $random.ToString()
$date =get-date -format yyMMddmmss
$date = $date.ToString()
$path2 = $random + "-" + $date
$path = "c:\temp\" + $path2 + "\"
$pathaz = "c:\temp\" + $path2 + "\az-landing-main"
$output3 = "c:\temp\" + $path2 + "\main.zip"
New-Item -ItemType Directory -Path $path
And set the paths into variables so each button can use them:
$jsonfile = [PSCustomObject]@{value=$pathaz+"\parameters.json"}
$path2 = [PSCustomObject]@{value=$path}
$pathaz2 = [PSCustomObject]@{value=$pathaz}
$output2 = [PSCustomObject]@{value=$output3}
Then finally launch the GUI:

Clicking Update Params populates the parameters.json file
Now onto the deployment
First in the tenant context we build the management groups
//Create Parent Management Group
resource parentmanagement 'Microsoft.Management/[email protected]' = {
name: orggroup
properties: {
displayName: orggroup
details: {
}
}
}
//Create Sub Groups
resource devmanagement 'Microsoft.Management/[email protected]' = {
name: devgroup
properties: {
displayName: 'string'
details: {
parent: {
id: parentmanagement.id
}
}
}
}
resource testmanagement 'Microsoft.Management/[email protected]' = {
name: testgroup
properties: {
displayName: 'string'
details: {
parent: {
id: parentmanagement.id
}
}
}
}
resource prodmanagement 'Microsoft.Management/[email protected]' = {
name: prodgroup
properties: {
displayName: 'string'
details: {
parent: {
id: parentmanagement.id
}
}
}
}
//Create Exceptions Management Group
resource exceptionsmanagement 'Microsoft.Management/[email protected]' = {
name: excgroup
properties: {
displayName: orggroup
details: {
}
}
}
We then drop to the subscription level to make some policies. I’ve set these to audit only for now, I’d recommend setting to Block after deployment of everything and checking the audit logs, last thing you want is a deployment to fail because it’s being blocked!
//Create Policy to restrict SKUs
targetScope = 'subscription'
param listOfAllowedLocations array = [
'uksouth'
'ukwest'
'northeurope'
'westeurope'
]
param listOfAllowedSKUs array = [
'Standard_B1ls'
'Standard_B1ms'
'Standard_B1s'
'Standard_B2ms'
'Standard_B2s'
'Standard_B4ms'
'Standard_B4s'
'Standard_D2s_v3'
'Standard_D4s_v3'
]
param tagname string
param tagvalue string
var initiativeDefinitionName = 'SKU and Location Policy'
resource initiativeDefinition 'Microsoft.Authorization/[email protected]' = {
name: initiativeDefinitionName
properties: {
policyType: 'Custom'
displayName: initiativeDefinitionName
description: 'Initiative Definition for Resource Location and VM SKUs'
metadata: {
category: 'SKU and Location Policy'
}
parameters: {
listOfAllowedLocations: {
type: 'Array'
metadata: ({
description: 'The List of Allowed Locations for Resource Groups and Resources.'
strongtype: 'location'
displayName: 'Allowed Locations'
})
}
listOfAllowedSKUs: {
type: 'Array'
metadata: any({
description: 'The List of Allowed SKUs for Virtual Machines.'
strongtype: 'vmSKUs'
displayName: 'Allowed Virtual Machine Size SKUs'
})
}
}
policyDefinitions: [
{
policyDefinitionId: '/providers/Microsoft.Authorization/policyDefinitions/e765b5de-1225-4ba3-bd56-1ac6695af988'
parameters: {
listOfAllowedLocations: {
value: '[parameters(\'listOfAllowedLocations\')]'
}
}
}
{
policyDefinitionId: '/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c'
parameters: {
listOfAllowedLocations: {
value: '[parameters(\'listOfAllowedLocations\')]'
}
}
}
{
policyDefinitionId: '/providers/Microsoft.Authorization/policyDefinitions/cccc23c7-8427-4f53-ad12-b6a63eb452b3'
parameters: {
listOfAllowedSKUs: {
value: '[parameters(\'listOfAllowedSKUs\')]'
}
}
}
{
policyDefinitionId: '/providers/Microsoft.Authorization/policyDefinitions/0015ea4d-51ff-4ce3-8d8c-f3f8f0179a56'
parameters: {}
}
]
}
}
resource initiativeDefinitionPolicyAssignment 'Microsoft.Authorization/[email protected]' = {
name: initiativeDefinitionName
properties: {
scope: subscription().id
enforcementMode: 'Default'
policyDefinitionId: initiativeDefinition.id
parameters: {
listOfAllowedLocations: {
value: listOfAllowedLocations
}
listOfAllowedSKUs: {
value: listOfAllowedSKUs
}
}
}
}
//Create Policy to require tag
resource tagpolicydef 'Microsoft.Authorization/[email protected]' = {
name: 'audit-tags'
properties: {
displayName: 'Audit a tag and its value format on resources'
description: 'Audits existence of a tag and its value format. Does not apply to resource groups.'
policyType: 'Custom'
mode: 'Indexed'
metadata: {
category: 'Tags'
}
parameters: {
tagName: {
type: 'String'
metadata: {
displayName: tagname
description: 'A tag to audit'
}
}
tagFormat: {
type: 'String'
metadata: {
displayName: tagvalue
description: 'An expressions for \'like\' condition' // Use backslash as an escape character for single quotation marks
}
}
}
policyRule: {
if: {
field: '[concat(\'tags[\', parameters(\'tagName\'), \']\')]' // No need to use an additional forward square bracket in the expressions as in ARM templates
notLike: '[parameters(\'tagFormat\')]'
}
then: {
effect: 'Audit'
}
}
}
}
resource policyAssignment 'Microsoft.Authorization/[email protected]' = {
name: 'deny-without-tags' //Should be unique whithin your target scope
properties: {
policyDefinitionId: tagpolicydef.id // Reference a policy specified in the same Bicep file
displayName: 'Deny anything without the \'tag_name\' on resources'
description: 'Policy will Deny resources not tagged with a specific tag'
parameters: {
tagName: {
value: tagname
}
tagFormat: {
value: tagvalue
}
}
}
}
//Create Policy to block public IPs
resource blockpublicip 'Microsoft.Authorization/[email protected]' = {
name: 'blockpublicip'
properties: {
displayName: 'Block Public IP'
description: 'Block network interface from having a public IP'
policyType: 'Custom'
mode: 'Indexed'
parameters: {}
policyRule: {
if: {
allOf: [
{
field: 'type'
equals: 'Microsoft.Network/networkInterface'
}
{
field: 'Microsoft.Network/networkInterfaces/ipconfigurations[*].publicIpAddress.id'
notlike: '*'
}
]
}
then: {
effect: 'Audit'
}
}
}
}
resource blocktheip 'Microsoft.Authorization/[email protected]' = {
name: 'block-public-ip' //Should be unique whithin your target scope
properties: {
policyDefinitionId: blockpublicip.id // Reference a policy specified in the same Bicep file
displayName: 'Audit Public IP'
description: 'Policy block public IP on network interfaces - Audit Only during build'
parameters: { }
}
}
Now we use the infra bicep to create the network and peerings. This calls a number of sub-files so I won’t go through the whole script. Importantly though, resource groups first:
resource hubrg 'Microsoft.Resources/[email protected]' = {
name: hubrgname
location: region
}
resource spokerg 'Microsoft.Resources/[email protected]' = {
name: spokergname
location: region
}
resource infrarg 'Microsoft.Resources/reso[email protected]' = {
name: serverrg
location: region
}
Build a VNET
param prefix string
param addressSpaces array
param subnets array
resource vnet 'Microsoft.Network/[email protected]' = {
name: prefix
location: resourceGroup().location
properties: {
addressSpace: {
addressPrefixes: addressSpaces
}
subnets: subnets
}
}
output name string = vnet.name
output id string = vnet.id
output subnet2 string = vnet.properties.subnets[1].id
Add your subnets (just the key bits here, grab the source file for full code)
name: 'AzureFirewallSubnet'
properties: {
addressPrefix: hubfwsubnet
}
}
{
name: 'GatewaySubnet'
properties: {
addressPrefix: vpnsubnet
Then the spoke (just the key bits here, grab the source file for full code):
name: spokesnname
properties: {
addressPrefix: spokesnspace
}
}
{
name: 'DeviceSubnet'
properties: {
addressPrefix: devicesubnet
resource vnet 'Microsoft.Network/[email protected]' = {
name: prefix
location: resourceGroup().location
properties: {
addressSpace: {
addressPrefixes: addressSpaces
}
subnets: subnets
}
}
Add a firewall:
param prefix string
param hubId string
resource publicIp 'Microsoft.Network/[email protected]' = {
name: '${prefix}-fwl-ip'
location: resourceGroup().location
properties: {
publicIPAddressVersion: 'IPv4'
publicIPAllocationMethod: 'Static'
}
sku: {
name: 'Standard'
}
}
resource fwl 'Microsoft.Network/[email protected]' = {
name: '${prefix}-fwl'
location: resourceGroup().location
properties: {
ipConfigurations: [
{
name: '${prefix}-fwl-ipconf'
properties: {
subnet: {
id: '${hubId}/subnets/AzureFirewallSubnet'
}
publicIPAddress: {
id: publicIp.id
}
}
}
]
}
}
output privateIp string = fwl.properties.ipConfigurations[0].properties.privateIPAddress
Peer the hub and spoke
module HubToSpokePeering './modules/peering.bicep' = {
name: 'hub-to-spoke-peering'
scope: hubrg
dependsOn: [
hubVNET
spokeVNET
]
params: {
localVnetName: hubVNET.outputs.name
remoteVnetName: spokename
remoteVnetId: spokeVNET.outputs.id
}
}
module SpokeToHubPeering './modules/peering.bicep' = {
name: 'spoke-to-hub-peering'
scope: spokerg
dependsOn: [
hubVNET
spokeVNET
]
params: {
localVnetName: spokeVNET.outputs.name
remoteVnetName: hubname
remoteVnetId: hubVNET.outputs.id
}
}
MODULES/PEERING:
resource peer 'Microsoft.Network/virtualNetworks/[email protected]' = {
name: '${localVnetName}/to-${remoteVnetName}'
properties: {
allowForwardedTraffic: false
allowGatewayTransit: false
allowVirtualNetworkAccess: true
useRemoteGateways: false
remoteVirtualNetwork: {
id: remoteVnetId
}
}
}
Create the VPN Gateway (remember to adjust for your on-prem VPN)
resource vpngwpip 'Microsoft.Network/[email protected]' = {
name: vpngwpipname
location: location
sku: {
name: 'Standard'
}
properties: {
publicIPAllocationMethod: 'Static'
}
}
resource vpngw 'Microsoft.Network/[email protected]' = {
name: vpngwname
location: location
properties: {
gatewayType: 'Vpn'
ipConfigurations: [
{
name: 'default'
properties: {
privateIPAllocationMethod: 'Dynamic'
subnet: {
id: subnetref
}
publicIPAddress: {
id: vpngwpip.id
}
}
}
]
activeActive: false
enableBgp: true
bgpSettings: {
asn: 65010
}
vpnType: 'RouteBased'
vpnGatewayGeneration: 'Generation1'
sku: {
name: 'VpnGw1AZ'
tier: 'VpnGw1AZ'
}
}
}
output id string = vpngw.id
output ip string = vpngwpip.properties.ipAddress
output bgpaddress string = vpngw.properties.bgpSettings.bgpPeeringAddress
resource localnetworkgw 'Microsoft.Network/[email protected]' = {
name: localnetworkgwname
location: location
properties: {
localNetworkAddressSpace: {
addressPrefixes: addressprefixes
}
gatewayIpAddress: gwipaddress
bgpSettings: {
asn: 64512
bgpPeeringAddress: bgppeeringpddress
}
}
}
Build a VM to test
param adminUserName string
@secure()
param adminPassword string
param dnsLabelPrefix string
param storageAccountName string
param vmName string
param subnetName string
param networkSecurityGroupName string
param vn string
@allowed([
'2008-R2-SP1'
'2012-Datacenter'
'2012-R2-Datacenter'
'2016-Nano-Server'
'2016-Datacenter-with-Containers'
'2016-Datacenter'
'2019-Datacenter'
])
@description('The Windows version for the VM. This will pick a fully patched image of this given Windows version.')
param windowsOSVersion string = '2019-Datacenter'
@description('Size of the virtual machine.')
param vmSize string = 'Standard_D2s_v3'
@description('location for all resources')
param location string = resourceGroup().location
var nicName = '${vmName}Nic'
var publicIPAddressName = '${vmName}PublicIP'
var subnetRef = '${vn}/subnets/${subnetName}'
resource stg 'Microsoft.Storage/[email protected]' = {
name: storageAccountName
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'Storage'
}
resource pip 'Microsoft.Network/[email protected]' = {
name: publicIPAddressName
location: location
properties: {
publicIPAllocationMethod: 'Dynamic'
dnsSettings: {
domainNameLabel: dnsLabelPrefix
}
}
}
resource sg 'Microsoft.Network/[email protected]' = {
name: networkSecurityGroupName
location: location
properties: {
securityRules: [
{
name: 'default-allow-3389'
'properties': {
priority: 1000
access: 'Allow'
direction: 'Inbound'
destinationPortRange: '3389'
protocol: 'Tcp'
sourcePortRange: '*'
sourceAddressPrefix: '*'
destinationAddressPrefix: '*'
}
}
]
}
}
resource nInter 'Microsoft.Network/[email protected]' = {
name: nicName
location: location
properties: {
ipConfigurations: [
{
name: 'ipconfig1'
properties: {
privateIPAllocationMethod: 'Dynamic'
publicIPAddress: {
id: pip.id
}
subnet: {
id: subnetRef
}
}
}
]
}
}
resource VM 'Microsoft.Compute/[email protected]' = {
name: vmName
location: location
properties: {
hardwareProfile: {
vmSize: vmSize
}
osProfile: {
computerName: vmName
adminUsername: adminUserName
adminPassword: adminPassword
}
storageProfile: {
imageReference: {
publisher: 'MicrosoftWindowsServer'
offer: 'WindowsServer'
sku: windowsOSVersion
version: 'latest'
}
osDisk: {
createOption: 'FromImage'
}
dataDisks: [
{
diskSizeGB: 1023
lun: 0
createOption: 'Empty'
}
]
}
networkProfile: {
networkInterfaces: [
{
id: nInter.id
}
]
}
diagnosticsProfile: {
bootDiagnostics: {
enabled: true
storageUri: stg.properties.primaryEndpoints.blob
}
}
}
}
output hostname string = pip.properties.dnsSettings.fqdn
Finally create your log analytics workspace and Azure monitor:
resource monitorrg 'Microsoft.Resources/[email protected]' = {
name: monitoringrg
location: logAnalyticslocation
}
//Azure Monitor and Log Analytics
module loganalytics './modules/monitor.bicep' = {
name: 'hubspoke'
scope: resourceGroup(monitorrg.name)
params: {
logAnalyticslocation: logAnalyticslocation
logAnalyticsWorkspaceName: logAnalyticsWorkspaceName
}
}
//Create Log Analytics Workspace
resource avdla 'Microsoft.OperationalInsights/[email protected]' = {
name: logAnalyticsWorkspaceName
location: logAnalyticslocation
properties: {
sku: {
name: logAnalyticsWorkspaceSku
}
}
}
And there we go, one landing zone.
Feel free to grab a copy of all of the files, if you change bicep for your environment, remember to change the download path in the powershell script:
#Download files and update parameters.json
$url = "https://github.com/andrew-s-taylor/az-landing/archive/main.zip"
$output = $output2.value
$expath = $path2.value
Next script will be building an Intune environment using powershell, POSHGUI and a pre-built set of policies etc. (yes, a one-click Intune to go with one-click AZ and one-click AVD)
Awsome