Building an Azure Landing Zone with Bicep

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/managementGroups@2020-05-01' = {
  name: orggroup 
  properties: {
    displayName: orggroup
    details: {
      }
    }
  }

  //Create Sub Groups

  resource devmanagement 'Microsoft.Management/managementGroups@2020-05-01' = {
    name: devgroup

    properties: {
      displayName: 'string'
      details: {
        parent: {
          id: parentmanagement.id
        }
      }
    }
  }

  resource testmanagement 'Microsoft.Management/managementGroups@2020-05-01' = {
    name: testgroup

    properties: {
      displayName: 'string'
      details: {
        parent: {
          id: parentmanagement.id
        }
      }
    }
  }

  resource prodmanagement 'Microsoft.Management/managementGroups@2020-05-01' = {
    name: prodgroup

    properties: {
      displayName: 'string'
      details: {
        parent: {
          id: parentmanagement.id
        }
      }
    }
  }


  //Create Exceptions Management Group

resource exceptionsmanagement 'Microsoft.Management/managementGroups@2020-05-01' = {
  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/policySetDefinitions@2019-09-01' = {
  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/policyAssignments@2019-09-01' = {
  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/policyDefinitions@2020-09-01' = {
  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/policyAssignments@2020-09-01' = {
  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/policyDefinitions@2020-09-01' = {
  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/policyAssignments@2020-09-01' = {
  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/resourceGroups@2020-06-01' = {
  name: hubrgname
  location: region
}

resource spokerg 'Microsoft.Resources/resourceGroups@2020-06-01' = {
  name: spokergname
  location: region
}

resource infrarg 'Microsoft.Resources/resourceGroups@2020-06-01' = {
  name: serverrg
  location: region
}

Build a VNET

param prefix string
param addressSpaces array
param subnets array

resource vnet 'Microsoft.Network/virtualNetworks@2020-06-01' = {
  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/virtualNetworks@2020-06-01' = {
  name: prefix
  location: resourceGroup().location
  properties: {
    addressSpace: {
      addressPrefixes: addressSpaces
    }
    subnets: subnets
  }
}

Add a firewall:

param prefix string
param hubId string

resource publicIp 'Microsoft.Network/publicIPAddresses@2020-06-01' = {
  name: '${prefix}-fwl-ip'
  location: resourceGroup().location
  properties: {
    publicIPAddressVersion: 'IPv4'
    publicIPAllocationMethod: 'Static'
  }
  sku: {
    name: 'Standard'
  }
}

resource fwl 'Microsoft.Network/azureFirewalls@2020-06-01' = {
  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/virtualNetworkPeerings@2020-07-01' = {
  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/publicIPAddresses@2020-06-01' = {
  name: vpngwpipname
  location: location
  sku: {
    name: 'Standard'
  }
  properties: {
    publicIPAllocationMethod: 'Static'    
  }
}

resource vpngw 'Microsoft.Network/virtualNetworkGateways@2020-06-01' = {
  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/localNetworkGateways@2020-06-01' = {
  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/storageAccounts@2019-06-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'Storage'
}

resource pip 'Microsoft.Network/publicIPAddresses@2020-06-01' = {
  name: publicIPAddressName
  location: location
  properties: {
    publicIPAllocationMethod: 'Dynamic'
    dnsSettings: {
      domainNameLabel: dnsLabelPrefix
    }
  }
}

resource sg 'Microsoft.Network/networkSecurityGroups@2020-06-01' = {
  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/networkInterfaces@2020-06-01' = {
  name: nicName
  location: location

  properties: {
    ipConfigurations: [
      {
        name: 'ipconfig1'
        properties: {
          privateIPAllocationMethod: 'Dynamic'
          publicIPAddress: {
            id: pip.id
          }
          subnet: {
            id: subnetRef
          }
        }
      }
    ]
  }
}

resource VM 'Microsoft.Compute/virtualMachines@2020-06-01' = {
  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/resourceGroups@2020-06-01' = {
  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/workspaces@2020-08-01' = {
  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)

1 thought on “Building an Azure Landing Zone with Bicep”

Leave a Comment