# Bicep - Solution Development Bicep solution development for the Azure Verified Modules (AVM). It covers the technical decisions and concepts that are important for building and deploying Azure solutions using AVM modules. Introduction Azure Verified Modules (AVM) for Bicep are a powerful tool that leverage the Bicep domain-specific language (DSL), industry knowledge, and an Open Source community, which altogether enable developers to quickly deploy Azure resources that follow Microsoft’s recommended practices for Azure. In this article, we will walk through the Bicep specific considerations and recommended practices on developing your solution leveraging Azure Verified Modules. We’ll review some of the design features and trade-offs and include sample code to illustrate each discussion point. In this tutorial, we will: Deploy a basic Virtual Machine architecture into Azure Explore recommended practices related to Bicep template development Demonstrate the ease with which you can deploy AVM modules Describe each of the development and deployment steps in detail After completing this tutorial, you’ll have a working knowledge of: How to discover and add AVM modules to your Bicep template How to reference and use outputs across AVM modules Recommended practices for parameterization and structure of your Bicep file Configuration of AVM modules to meet Microsoft’s Well Architected Framework (WAF) principles How to deploy your Bicep template into an Azure subscription from your local machine Let’s get started! Prerequisites title: “Bicep Prerequisites” description: “Learn about the prerequisites for using Bicep to deploy Azure Verified Modules or develop them.” You will need the following tools and components to complete this guide: Visual Studio Code (VS Code) to develop your solution. Bicep Visual Studio Code Extension to author your Bicep template and explore modules published in the Registry. One of the following command line tools: PowerShell AND Azure PowerShell Azure CLI to deploy your solution. Bicep CLI Azure Subscription to deploy your Bicep templates. Before you begin, make sure you have these tools installed in your development environment. Solution Architecture Before we begin coding, it is important to have details about what the infrastructure architecture will include. For our example, we will be building a solution that will host a simple application on a Linux virtual machine (VM). The solution must be secure and auditable. The VM must not be accessible from the internet and its logs should be easily accessible. All azure services should utilize logging tools for auditing purposes. Develop the Solution Code Creating the main.bicep file The architecture diagram shows all components needed for a successful solution deployment. Rather than building the complete solution at once, this tutorial takes an incremental approach building the Bicep file piece-by-piece and testing the deployment at each stage. This approach allows for discussion of each design decision along the way. The development will start with core platform components: first the backend logging services (Log Analytics) and then the virtual network. Let’s begin by creating our folder structure along with a main.bicep file. Your folder structure should be as follows: VirtualMachineAVM_Example1/ └── main.bicep After you have your folder structure and main.bicep file, we can proceed with our first AVM resources! Log Analytics Let’s start by adding a logging service to our main.bicep since all other deployed resources will use this service for their logs. Tip Always begin template development by adding resources that create dependencies for other downstream services. This approach simplifies referencing these dependencies within your other modules as you develop them. For example, starting with Logging and Virtual Network services makes sense since all other services will depend on these. The logging solution depicted in our Architecture Diagram shows we will be using a Log Analytics workspace. Let’s add that to our template! Open your main.bicep file and add the following: ➕ Expand Code 1module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 2 name: 'logAnalyticsWorkspace' 3 params: { 4 // Required parameters 5 name: 'VM-AVM-Ex1-law' 6 // Non-required parameters 7 location: 'westus2' 8 } 9} Note Always click on the “Copy to clipboard” button in the top right corner of the Code sample area in order not to have the line numbers included in the copied code. You now have a fully functional Bicep template that will deploy a working Log Analytics workspace! If you would like to try it, run the following in your console: Note For keeping the example below simple, we are using the traditional deployment commands, e.g., az deployment group create or New-AzResourceGroupDeployment. However, we encourage you to look into using Deployment Stacks instead by simply replacing the previous commands with az stack group create or New-AzResourceGroupDeploymentStack as well as the other required input parameters as shown here. Deployment Stacks allow you to deploy a Bicep file as a stack, which is a collection of resources that are deployed together. This allows you to manage the lifecycle of the stack as a single unit, making it easier to deploy, update, and now even delete resources via Bicep. You can also implement RBAC Deny Assignments on your stacks deployed resources to prevent changes to the resources or specific actions on the resources to all but an excluded list of users, groups or other principals. Deploy with PowerShell AZ CLI # Log in to Azure Connect-AzAccount # Select your subscription Set-AzContext -SubscriptionId '<subscriptionId>' # Deploy a resource group New-AzResourceGroup -Name 'avm-bicep-vmexample1' -Location '<location>' # Invoke your deployment New-AzResourceGroupDeployment -DeploymentName 'avm-bicep-vmexample1-deployment' -ResourceGroupName 'avm-bicep-vmexample1' -TemplateFile '/<path-to>/VirtualMachineAVM_Example1/main.bicep' # Log in to Azure az login # Select your subscription az account set --subscription '<subscriptionId>' # Deploy a resource group az group create --name 'avm-bicep-vmexample1' --location '<location>' # Invoke your deployment az deployment group create --name 'avm-bicep-vmexample1-deployment' --resource-group 'avm-bicep-vmexample1' --template-file '/<path-to>/VirtualMachineAVM_Example1/main.bicep' The above commands will log you in to your Azure subscription, select a subscription to use, create a resource group, then deploy the main.bicep template to your resource group. AVM Makes the deployment of Azure resources incredibly easy. Many of the parameters you would normally be required to define are taken care of by the AVM module itself. In fact, the location parameter is not even needed in your template—when left blank, by default, all AVM modules will deploy to the location in which your target Resource Group exists. Now we have a Log Analytics workspace in our resource group which doesn’t do a whole lot of good on its own. Let’s take our template a step further by adding a Virtual Network that integrates with the Log Analytics workspace. Virtual Network We will now add a Virtual Network to our main.bicep file. This VNet will contain subnets and Network Security Groups (NSGs) for any of the resources we deploy that require IP addresses. In your main.bicep file, add the following: ➕ Expand Code 1module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 2 name: 'logAnalyticsWorkspace' 3 params: { 4 // Required parameters 5 name: 'VM-AVM-Ex1-law' 6 // Non-required parameters 7 location: 'westus2' 8 } 9} 10 11module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 12 name: 'virtualNetworkDeployment' 13 params: { 14 // Required parameters 15 addressPrefixes: [ 16 '10.0.0.0/16' 17 ] 18 name: 'VM-AVM-Ex1-vnet' 19 // Non-required parameters 20 location: 'westus2' 21 } 22} Again, the Virtual Network AVM module requires only two things: a name and an addressPrefixes parameter. Configure Diagnostics Settings There is an additional parameter available in most AVM modules named diagnosticSettings. This parameter allows you to configure your resource to send its logs to any suitable logging service. In our case, we are using a Log Analytics workspace. Let’s update our main.bicep file to have our VNet send all of its logging data to our Log Analytics workspace: ➕ Expand Code 1module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 2 name: 'logAnalyticsWorkspace' 3 params: { 4 // Required parameters 5 name: 'VM-AVM-Ex1-law' 6 // Non-required parameters 7 location: 'westus2' 8 } 9} 10 11module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 12 name: 'virtualNetworkDeployment' 13 params: { 14 // Required parameters 15 addressPrefixes: [ 16 '10.0.0.0/16' 17 ] 18 name: 'VM-AVM-Ex1-vnet' 19 // Non-required parameters 20 location: 'westus2' 21 diagnosticSettings: [ 22 { 23 name: 'vNetDiagnostics' 24 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 25 } 26 ] 27 } 28} Notice how the diagnosticsSettings parameter needs a workspaceResourceId? All you need to do is add a reference to the built-in logAnalyticsWorkspaceId output of the logAnalyticsWorkspace AVM module. That’s it! Our VNet now has integrated its logging with our Log Analytics workspace. All AVM modules come with a set of built-in outputs that can be easily referenced by other modules within your template. Info All AVM modules have built-in outputs which can be referenced using the <moduleName>.outputs.<outputName> syntax. When using plain Bicep, many of these outputs require multiple lines of code or knowledge of the correct object ID references to get at the desired output. AVM modules do much of this heavy lifting for you by taking care of these complex tasks within the module itself, then exposing them to you through the module’s outputs. Find out more about Bicep Outputs. Add a Subnet and NAT Gateway We can’t use a Virtual Network without subnets, so let’s add a subnet next. According to our Architecture, we will have three subnets: one for the Virtual Machine, one for the Bastion host, and one for Private Endpoints. We can start with the VM subnet for now. While we’re at it, let’s also add the NAT Gateway, the NAT Gateway’s Public IP, the attach the NAT Gateway to the VM subnet. Add the following to your main.bicep: ➕ Expand Code 1module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 2 name: 'logAnalyticsWorkspace' 3 params: { 4 // Required parameters 5 name: 'VM-AVM-Ex1-law' 6 // Non-required parameters 7 location: 'westus2' 8 } 9} 10 11module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = { 12 name: 'natGwPublicIpDeployment' 13 params: { 14 // Required parameters 15 name: 'VM-AVM-Ex1-natgwpip' 16 // Non-required parameters 17 location: 'westus2' 18 diagnosticSettings: [ 19 { 20 name: 'natGwPublicIpDiagnostics' 21 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 22 } 23 ] 24 } 25} 26 27module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = { 28 name: 'natGatewayDeployment' 29 params: { 30 // Required parameters 31 name: 'VM-AVM-Ex1-natGw' 32 zone: 1 33 // Non-required parameters 34 publicIpResourceIds: [ 35 natGwPublicIp.outputs.resourceId 36 ] 37 } 38} 39 40module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 41 name: 'virtualNetworkDeployment' 42 params: { 43 // Required parameters 44 addressPrefixes: [ 45 '10.0.0.0/16' 46 ] 47 name: 'VM-AVM-Ex1-vnet' 48 // Non-required parameters 49 location: 'westus2' 50 diagnosticSettings: [ 51 { 52 name: 'vNetDiagnostics' 53 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 54 } 55 ] 56 subnets: [ 57 { 58 name: 'VMSubnet' 59 addressPrefix: cidrSubnet('10.0.0.0/16', 24, 0) // first subnet in address space 60 natGatewayResourceId: natGateway.outputs.resourceId 61 } 62 ] 63 } 64} The modification adds a subnets property to our virtualNetwork module. The AVM network/virtual-network module supports the creation of subnets directly within the module itself. We can also link our NAT Gateway directly to the subnet within this submodule. A nice feature within Bicep are the various functions available. We use the cidrSubnet() function to declare CIDR blocks without having to calculate them on your own. Switch to Parameters and Variables See how we are reusing the same CIDR block 10.0.0.0/16 in multiple locations? You may have noticed we are defining the same location in two different spots as well. We’re now at a point in the development where we should leverage one of our first recommended practices: using parameters and variables! Tip Use Bicep variables to define values that will be constant and reused with your template; use parameters anywhere you may need a modifiable value. Let’s enhance the template by adding variables for the CIDR block and prefix, then use a location parameter with a default value. We’ll then reference those in the module: ➕ Expand Code 1param location string = 'westus2' 2 3var addressPrefix = '10.0.0.0/16' 4var prefix = 'VM-AVM-Ex1' 5 6module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 7 name: 'logAnalyticsWorkspace' 8 params: { 9 // Required parameters 10 name: '${prefix}-law' 11 // Non-required parameters 12 location: location 13 } 14} 15 16module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = { 17 name: 'natGwPublicIpDeployment' 18 params: { 19 // Required parameters 20 name: '${prefix}-natgwpip' 21 // Non-required parameters 22 location: location 23 diagnosticSettings: [ 24 { 25 name: 'natGwPublicIpDiagnostics' 26 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 27 } 28 ] 29 } 30} 31 32module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = { 33 name: 'natGatewayDeployment' 34 params: { 35 // Required parameters 36 name: '${prefix}-natgw' 37 zone: 1 38 // Non-required parameters 39 publicIpResourceIds: [ 40 natGwPublicIp.outputs.resourceId 41 ] 42 } 43} 44 45module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 46 name: 'virtualNetworkDeployment' 47 params: { 48 // Required parameters 49 addressPrefixes: [ 50 addressPrefix 51 ] 52 name: '${prefix}-vnet' 53 // Non-required parameters 54 location: location 55 diagnosticSettings: [ 56 { 57 name: 'vNetDiagnostics' 58 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 59 } 60 ] 61 subnets: [ 62 { 63 name: 'VMSubnet' 64 addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space 65 natGatewayResourceId: natGateway.outputs.resourceId 66 } 67 ] 68 } 69} We now have a good basis for the infrastructure to be utilized by the rest of the resources in our Architecture. We will come back to our networking in a future step, once we are ready to create some Network Security Groups. For now, let’s move on to other modules. Key Vault Key Vaults are one of the key components in most Azure architectures as they create a place where you can save and reference secrets in a secure manner (“secrets” in the general sense, as opposed to the secret object type in Key Vaults). The Key Vault AVM module makes it very simple to store secrets generated in your template. In this tutorial, we will use one of the most secure methods of storing and retrieving secrets by leveraging this Key Vault in our Bicep template. The first step is easy: add the Key Vault AVM module to our main.bicep file. In addition, let’s also ensure it’s hooked into our Log Analytics workspace (we will do this for every new module from here on out). ➕ Expand Code 1param location string = 'westus2' 2 3var addressPrefix = '10.0.0.0/16' 4var prefix = 'VM-AVM-Ex1' 5 6module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 7 name: 'logAnalyticsWorkspace' 8 params: { 9 // Required parameters 10 name: '${prefix}-law' 11 // Non-required parameters 12 location: location 13 } 14} 15 16module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = { 17 name: 'natGwPublicIpDeployment' 18 params: { 19 // Required parameters 20 name: '${prefix}-natgwpip' 21 // Non-required parameters 22 location: location 23 diagnosticSettings: [ 24 { 25 name: 'natGwPublicIpDiagnostics' 26 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 27 } 28 ] 29 } 30} 31 32module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = { 33 name: 'natGatewayDeployment' 34 params: { 35 // Required parameters 36 name: '${prefix}-natgw' 37 zone: 1 38 // Non-required parameters 39 publicIpResourceIds: [ 40 natGwPublicIp.outputs.resourceId 41 ] 42 } 43} 44 45module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 46 name: 'virtualNetworkDeployment' 47 params: { 48 // Required parameters 49 addressPrefixes: [ 50 addressPrefix 51 ] 52 name: '${prefix}-vnet' 53 // Non-required parameters 54 location: location 55 diagnosticSettings: [ 56 { 57 name: 'vNetDiagnostics' 58 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 59 } 60 ] 61 subnets: [ 62 { 63 name: 'VMSubnet' 64 addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space 65 natGatewayResourceId: natGateway.outputs.resourceId 66 } 67 ] 68 } 69} 70 71module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = { 72 name: 'keyVaultDeployment' 73 params: { 74 // Required parameters 75 name: '${uniqueString(resourceGroup().id)}-kv' 76 // Non-required parameters 77 location: location 78 diagnosticSettings: [ 79 { 80 name: 'keyVaultDiagnostics' 81 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 82 } 83 ] 84 } 85} The name of the Key Vault we will deploy uses the uniqueString() Bicep function. Key Vault names must be globally unique. We will therefore deviate from our standard naming convention thus far and make an exception for the Key Vault. Note how we are still adding a suffix to the Key Vault name, so its name remains recognizable; you can use a combination of concatenating unique strings, prefixes, or suffixes to follow your own naming standard preferences. When we generate our unique string, we will pass in the resourceGroup().id as the seed for the uniqueString() function so that every time you deploy this main.bicep to the same resource group, it will use the same randomly generated name for your Key Vault (since resourceGroup().id will be the same). Tip Bicep has many built-in functions available. We used two here: uniqueString() and resourceGroup(). The resourceGroup(), subscription(), and deployment() functions are very useful when seeding uniqueString() or guid() functions. Just be cautious about name length limitations for each Azure service! Visit this page to learn more about Bicep functions. We will use this Key Vault later on when we create a VM and need to store its password. Now that we have it, a Virtual Network, Subnet, and Log Analytics prepared, we should have everything we need to deploy a Virtual Machine! Info In the future, we will update this guide to show how to generate and store a certificate in the Key Vault, then use that certificate to authenticate into the Virtual Machine. Virtual Machine Warning The AVM Virtual Machine module enables the EncryptionAtHost feature by default. You must enable this feature within your Azure subscription successfully deploy this example code. To do so, run the following: Deploy with PowerShell AZ CLI # Wait a few minutes after running the command to allow it to propagate Register-AzProviderFeature -FeatureName "EncryptionAtHost" -ProviderNamespace "Microsoft.Compute" az feature register --namespace Microsoft.Compute --name EncryptionAtHost # Propagate the change az provider register --namespace Microsoft.Compute For our Virtual Machine (VM) deployment, we need to add the following to our main.bicep file: ➕ Expand Code 1param location string = 'westus2' 2 3// START add-password-param 4@description('Required. A password for the VM admin user.') 5@secure() 6param vmAdminPass string 7// END add-password-param 8 9var addressPrefix = '10.0.0.0/16' 10var prefix = 'VM-AVM-Ex1' 11 12module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 13 name: 'logAnalyticsWorkspace' 14 params: { 15 // Required parameters 16 name: '${prefix}-law' 17 // Non-required parameters 18 location: location 19 } 20} 21 22module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = { 23 name: 'natGwPublicIpDeployment' 24 params: { 25 // Required parameters 26 name: '${prefix}-natgwpip' 27 // Non-required parameters 28 location: location 29 diagnosticSettings: [ 30 { 31 name: 'natGwPublicIpDiagnostics' 32 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 33 } 34 ] 35 } 36} 37 38module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = { 39 name: 'natGatewayDeployment' 40 params: { 41 // Required parameters 42 name: '${prefix}-natgw' 43 zone: 1 44 // Non-required parameters 45 publicIpResourceIds: [ 46 natGwPublicIp.outputs.resourceId 47 ] 48 } 49} 50 51module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 52 name: 'virtualNetworkDeployment' 53 params: { 54 // Required parameters 55 addressPrefixes: [ 56 addressPrefix 57 ] 58 name: '${prefix}-vnet' 59 // Non-required parameters 60 location: location 61 diagnosticSettings: [ 62 { 63 name: 'vNetDiagnostics' 64 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 65 } 66 ] 67 subnets: [ 68 { 69 name: 'VMSubnet' 70 addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space 71 natGatewayResourceId: natGateway.outputs.resourceId 72 } 73 ] 74 } 75} 76 77module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = { 78 name: 'keyVaultDeployment' 79 params: { 80 // Required parameters 81 name: '${uniqueString(resourceGroup().id)}-kv' 82 // Non-required parameters 83 location: location 84 diagnosticSettings: [ 85 { 86 name: 'keyVaultDiagnostics' 87 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 88 } 89 ] 90 // START add-keyvault-secret 91 secrets: [ 92 { 93 name: 'vmAdminPassword' 94 value: vmAdminPass 95 } 96 ] 97 // END add-keyvault-secret 98 } 99} 100 101module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.13.1' = { 102 name: 'linuxVirtualMachineDeployment' 103 params: { 104 // Required parameters 105 adminUsername: 'localAdminUser' 106 adminPassword: vmAdminPass 107 imageReference: { 108 offer: '0001-com-ubuntu-server-jammy' 109 publisher: 'Canonical' 110 sku: '22_04-lts-gen2' 111 version: 'latest' 112 } 113 name: '${prefix}-vm1' 114 // START vm-subnet-reference 115 nicConfigurations: [ 116 { 117 ipConfigurations: [ 118 { 119 name: 'ipconfig01' 120 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet 121 } 122 ] 123 nicSuffix: '-nic-01' 124 } 125 ] 126 // END vm-subnet-reference 127 osDisk: { 128 caching: 'ReadWrite' 129 diskSizeGB: 128 130 managedDisk: { 131 storageAccountType: 'Standard_LRS' 132 } 133 } 134 osType: 'Linux' 135 vmSize: 'Standard_B2s_v2' 136 zone: 0 137 // Non-required parameters 138 location: location 139 } 140} The VM module is one of the more complex modules in AVM—behind the scenes, it takes care of a lot of heavy lifting that, without AVM, would require multiple Bicep resources to be deployed and referenced. For example, look at the nicConfigurations parameter: normally, you would need to deploy a separate NIC resource, which itself also requires an IP resource, then attach them to each other, and finally, attach them all to your VM. With the AVM VM module, the nicConfigurations parameter accepts an object, allowing you to create any number of NICs to attach to your VM from within the VM resource deployment itself. It handles all the naming, creation of other necessary dependencies, and attaches them all together, so you don’t have to. The osDisk parameter is similar, though slightly less complex. There are many more parameters within the VM module that you can leverage if needed, that share a similar ease-of-use. Since this is the real highlight of our main.bicep file, we need to take a closer look at some of the other changes that were made. VM Admin Password Parameter 1@description('Required. A password for the VM admin user.') 2@secure() 3param vmAdminPass string First, we added a new parameter. The value of this will be provided when the main.bicep template is deployed. We don’t want any passwords stored as text in code; for our purposes, the safest way to do this is to prompt the end user for the password at the time of deployment. Warning The supplied password must be between 6-72 characters long and must satisfy at least 3 of password complexity requirements from the following: Contains an uppercase character; Contains a lowercase character; Contains a numeric digit; Contains a special character. Control characters are not allowed Also note how we are using the @secure() decorator on the password parameter. This will ensure the value of the password is never displayed in any of the deployment logs or in Azure. We have also added the @description() decorator and started the description with “Required.” It’s a good habit and recommended practice to document your parameters in Bicep. This will ensure that VS Code’s built-in Bicep linter can provide end-users insightful information when deploying your Bicep templates. Info Always use the @secure() decorator when creating a parameter that will hold sensitive data! Add the VM Admin Password to Key Vault 1 secrets: [ 2 { 3 name: 'vmAdminPassword' 4 value: vmAdminPass 5 } 6 ] The next thing we have done is save the value of our vmAdminPass parameter to our Key Vault. We have done this by adding a secrets parameter to the Key Vault module. Adding secrets to Key Vaults is very simple when using the AVM module. By adding our password to the Key Vault, it will ensure that we never lose the password and that it is stored securely. As long as a user has appropriate permissions on the vault, the password can be fetched easily. Reference the VM Subnet 1 nicConfigurations: [ 2 { 3 ipConfigurations: [ 4 { 5 name: 'ipconfig01' 6 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet 7 } 8 ] 9 nicSuffix: '-nic-01' 10 } 11 ] Here, we reference another built-in output, this time from the AVM Virtual Network module. This example shows how to use an output that is part of an array. When the Virtual Network module creates subnets, it automatically creates a set of pre-defined outputs for them, one of which is an array that contains each subnet’s subnetResourceId. Our VM Subnet was the first one created which is position [0] in the array. Other AVM modules may make use of arrays to store outputs. If you are unsure what type of outputs a module provides, you can always reference the Outputs section of each module’s README.md. Storage Account The last major component we need to add is a Storage Account. Because this Storage Account will be used as a backend storage to hold blobs for the hypothetical application that runs on our VM, we’ll also create a blob container within it using the same AVM Storage Account module. ➕ Expand Code 1param location string = 'westus2' 2 3@description('Required. A password for the VM admin user.') 4@secure() 5param vmAdminPass string 6 7var addressPrefix = '10.0.0.0/16' 8var prefix = 'VM-AVM-Ex1' 9 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 11 name: 'logAnalyticsWorkspace' 12 params: { 13 // Required parameters 14 name: '${prefix}-law' 15 // Non-required parameters 16 location: location 17 } 18} 19 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = { 21 name: 'natGwPublicIpDeployment' 22 params: { 23 // Required parameters 24 name: '${prefix}-natgwpip' 25 // Non-required parameters 26 location: location 27 diagnosticSettings: [ 28 { 29 name: 'natGwPublicIpDiagnostics' 30 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 31 } 32 ] 33 } 34} 35 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = { 37 name: 'natGatewayDeployment' 38 params: { 39 // Required parameters 40 name: '${prefix}-natgw' 41 zone: 1 42 // Non-required parameters 43 publicIpResourceIds: [ 44 natGwPublicIp.outputs.resourceId 45 ] 46 } 47} 48 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 50 name: 'virtualNetworkDeployment' 51 params: { 52 // Required parameters 53 addressPrefixes: [ 54 addressPrefix 55 ] 56 name: '${prefix}-vnet' 57 // Non-required parameters 58 location: location 59 diagnosticSettings: [ 60 { 61 62 name: 'vNetDiagnostics' 63 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 64 } 65 ] 66 subnets: [ 67 { 68 name: 'VMSubnet' 69 addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space 70 natGatewayResourceId: natGateway.outputs.resourceId 71 } 72 ] 73 } 74} 75 76module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = { 77 name: 'keyVaultDeployment' 78 params: { 79 // Required parameters 80 name: '${uniqueString(resourceGroup().id)}-kv' 81 // Non-required parameters 82 location: location 83 diagnosticSettings: [ 84 { 85 name: 'keyVaultDiagnostics' 86 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 87 } 88 ] 89 enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it 90 secrets: [ 91 { 92 name: 'vmAdminPassword' 93 value: vmAdminPass 94 } 95 ] 96 } 97} 98 99module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.13.1' = { 100 name: 'linuxVirtualMachineDeployment' 101 params: { 102 // Required parameters 103 adminUsername: 'localAdminUser' 104 adminPassword: vmAdminPass 105 imageReference: { 106 offer: '0001-com-ubuntu-server-jammy' 107 publisher: 'Canonical' 108 sku: '22_04-lts-gen2' 109 version: 'latest' 110 } 111 name: '${prefix}-vm1' 112 nicConfigurations: [ 113 { 114 ipConfigurations: [ 115 { 116 name: 'ipconfig01' 117 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet 118 } 119 ] 120 nicSuffix: '-nic-01' 121 } 122 ] 123 osDisk: { 124 caching: 'ReadWrite' 125 diskSizeGB: 128 126 managedDisk: { 127 storageAccountType: 'Standard_LRS' 128 } 129 } 130 131 osType: 'Linux' 132 vmSize: 'Standard_B2s_v2' 133 zone: 0 134 // Non-required parameters 135 location: location 136 } 137} 138 139module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = { 140 name: 'storageAccountDeployment' 141 params: { 142 // Required parameters 143 name: '${uniqueString(resourceGroup().id)}sa' 144 // Non-required parameters 145 location: location 146 skuName: 'Standard_LRS' 147 diagnosticSettings: [ 148 { 149 name: 'storageAccountDiagnostics' 150 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 151 } 152 ] 153 blobServices: { 154 containers: [ 155 { 156 name: 'vmstorage' 157 publicAccess: 'None' 158 } 159 ] 160 } 161 } 162} We now have all the major components of our Architecture diagram built! The last steps we need to take to meet our requirements is to ensure our networking resources are secure and that we are using least privileged access by leveraging Role-Based Access Control (RBAC). Let’s get to it! Network Security Groups We’ll add a Network Security Group (NSG) to our VM subnet. This will act as a layer 3 and layer 4 firewall for networked resources. This implementation includes an appropriate inbound rule to allow SSH traffic from the Bastion host: ➕ Expand Code 1param location string = 'westus2' 2 3@description('Required. A password for the VM admin user.') 4@secure() 5param vmAdminPass string 6 7var addressPrefix = '10.0.0.0/16' 8var prefix = 'VM-AVM-Ex1' 9 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 11 name: 'logAnalyticsWorkspace' 12 params: { 13 // Required parameters 14 name: '${prefix}-law' 15 // Non-required parameters 16 location: location 17 } 18} 19 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = { 21 name: 'natGwPublicIpDeployment' 22 params: { 23 // Required parameters 24 name: '${prefix}-natgwpip' 25 // Non-required parameters 26 location: location 27 diagnosticSettings: [ 28 { 29 name: 'natGwPublicIpDiagnostics' 30 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 31 } 32 ] 33 } 34} 35 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = { 37 name: 'natGatewayDeployment' 38 params: { 39 // Required parameters 40 name: '${prefix}-natgw' 41 zone: 1 42 // Non-required parameters 43 publicIpResourceIds: [ 44 natGwPublicIp.outputs.resourceId 45 ] 46 } 47} 48 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 50 name: 'virtualNetworkDeployment' 51 params: { 52 // Required parameters 53 addressPrefixes: [ 54 addressPrefix 55 ] 56 name: '${prefix}-vnet' 57 // Non-required parameters 58 location: location 59 diagnosticSettings: [ 60 { 61 name: 'vNetDiagnostics' 62 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 63 } 64 ] 65 subnets: [ 66 { 67 name: 'VMSubnet' 68 addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space 69 natGatewayResourceId: natGateway.outputs.resourceId 70 networkSecurityGroupResourceId: nsgVM.outputs.resourceId 71 } 72 ] 73 } 74} 75 76module nsgVM 'br/public:avm/res/network/network-security-group:0.5.1' = { 77 name: 'nsgVmDeployment' 78 params: { 79 name: '${prefix}-NSG-VM' 80 location: location 81 securityRules: [ 82 { 83 name: 'AllowBastionSSH' 84 properties: { 85 access: 'Allow' 86 direction: 'Inbound' 87 priority: 100 88 protocol: 'Tcp' 89 sourceAddressPrefix: 'virtualNetwork' 90 sourcePortRange: '*' 91 destinationAddressPrefix: '*' 92 destinationPortRange: '22' 93 } 94 } 95 ] 96 } 97} 98 99module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = { 100 name: 'keyVaultDeployment' 101 params: { 102 // Required parameters 103 name: '${uniqueString(resourceGroup().id)}-kv' 104 // Non-required parameters 105 location: location 106 diagnosticSettings: [ 107 { 108 name: 'keyVaultDiagnostics' 109 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 110 } 111 ] 112 enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it 113 secrets: [ 114 { 115 name: 'vmAdminPassword' 116 value: vmAdminPass 117 } 118 ] 119 } 120} 121 122module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.13.1' = { 123 name: 'linuxVirtualMachineDeployment' 124 params: { 125 // Required parameters 126 adminUsername: 'localAdminUser' 127 adminPassword: vmAdminPass 128 imageReference: { 129 offer: '0001-com-ubuntu-server-jammy' 130 publisher: 'Canonical' 131 sku: '22_04-lts-gen2' 132 version: 'latest' 133 } 134 name: '${prefix}-vm1' 135 nicConfigurations: [ 136 { 137 ipConfigurations: [ 138 { 139 name: 'ipconfig01' 140 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet 141 } 142 ] 143 nicSuffix: '-nic-01' 144 } 145 ] 146 osDisk: { 147 caching: 'ReadWrite' 148 diskSizeGB: 128 149 managedDisk: { 150 storageAccountType: 'Standard_LRS' 151 } 152 } 153 154 osType: 'Linux' 155 vmSize: 'Standard_B2s_v2' 156 zone: 0 157 // Non-required parameters 158 location: location 159 } 160} 161 162module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = { 163 name: 'storageAccountDeployment' 164 params: { 165 // Required parameters 166 name: '${uniqueString(resourceGroup().id)}sa' 167 // Non-required parameters 168 location: location 169 skuName: 'Standard_LRS' 170 diagnosticSettings: [ 171 { 172 name: 'storageAccountDiagnostics' 173 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 174 } 175 ] 176 blobServices: { 177 containers: [ 178 { 179 name: 'vmstorage' 180 publicAccess: 'None' 181 } 182 ] 183 } 184 } 185} Disable Public Access to Storage Account Since the Storage Account serves as a backend resource exclusively for the Virtual Machine, it will be secured as much as possible. This involves adding a Private Endpoint and disabling public internet access. AVM makes creation and assignment of Private Endpoints to resources incredibly easy. Take a look: ➕ Expand Code 1param location string = 'westus2' 2 3@description('Required. A password for the VM admin user.') 4@secure() 5param vmAdminPass string 6 7var addressPrefix = '10.0.0.0/16' 8var prefix = 'VM-AVM-Ex1' 9 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 11 name: 'logAnalyticsWorkspace' 12 params: { 13 // Required parameters 14 name: '${prefix}-law' 15 // Non-required parameters 16 location: location 17 } 18} 19 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = { 21 name: 'natGwPublicIpDeployment' 22 params: { 23 // Required parameters 24 name: '${prefix}-natgwpip' 25 // Non-required parameters 26 location: location 27 diagnosticSettings: [ 28 { 29 name: 'natGwPublicIpDiagnostics' 30 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 31 } 32 ] 33 } 34} 35 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = { 37 name: 'natGatewayDeployment' 38 params: { 39 // Required parameters 40 name: '${prefix}-natgw' 41 zone: 1 42 // Non-required parameters 43 publicIpResourceIds: [ 44 natGwPublicIp.outputs.resourceId 45 ] 46 } 47} 48 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 50 name: 'virtualNetworkDeployment' 51 params: { 52 // Required parameters 53 addressPrefixes: [ 54 addressPrefix 55 ] 56 name: '${prefix}-vnet' 57 // Non-required parameters 58 location: location 59 diagnosticSettings: [ 60 { 61 name: 'vNetDiagnostics' 62 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 63 } 64 ] 65 subnets: [ 66 { 67 name: 'VMSubnet' 68 addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space 69 natGatewayResourceId: natGateway.outputs.resourceId 70 networkSecurityGroupResourceId: nsgVM.outputs.resourceId 71 } 72 { 73 name: 'PrivateEndpointSubnet' 74 addressPrefix: cidrSubnet(addressPrefix, 24, 1) // second subnet in address space 75 } 76 ] 77 } 78} 79 80module nsgVM 'br/public:avm/res/network/network-security-group:0.5.1' = { 81 name: 'nsgVmDeployment' 82 params: { 83 name: '${prefix}-NSG-VM' 84 location: location 85 securityRules: [ 86 { 87 name: 'AllowBastionSSH' 88 properties: { 89 access: 'Allow' 90 direction: 'Inbound' 91 priority: 100 92 protocol: 'Tcp' 93 sourceAddressPrefix: 'virtualNetwork' 94 sourcePortRange: '*' 95 destinationAddressPrefix: '*' 96 destinationPortRange: '22' 97 } 98 } 99 ] 100 } 101} 102 103module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = { 104 name: 'keyVaultDeployment' 105 params: { 106 // Required parameters 107 name: '${uniqueString(resourceGroup().id)}-kv' 108 // Non-required parameters 109 location: location 110 diagnosticSettings: [ 111 { 112 name: 'keyVaultDiagnostics' 113 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 114 } 115 ] 116 enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it 117 secrets: [ 118 { 119 name: 'vmAdminPassword' 120 value: vmAdminPass 121 } 122 ] 123 } 124} 125 126module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.14.0' = { 127 name: 'linuxVirtualMachineDeployment' 128 params: { 129 // Required parameters 130 adminUsername: 'localAdminUser' 131 adminPassword: vmAdminPass 132 imageReference: { 133 offer: '0001-com-ubuntu-server-jammy' 134 publisher: 'Canonical' 135 sku: '22_04-lts-gen2' 136 version: 'latest' 137 } 138 name: '${prefix}-vm1' 139 nicConfigurations: [ 140 { 141 ipConfigurations: [ 142 { 143 name: 'ipconfig01' 144 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet 145 } 146 ] 147 nicSuffix: '-nic-01' 148 } 149 ] 150 osDisk: { 151 caching: 'ReadWrite' 152 diskSizeGB: 128 153 managedDisk: { 154 storageAccountType: 'Standard_LRS' 155 } 156 } 157 osType: 'Linux' 158 vmSize: 'Standard_B2s_v2' 159 zone: 0 160 // Non-required parameters 161 location: location 162 } 163} 164 165module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = { 166 name: 'storageAccountDeployment' 167 params: { 168 // Required parameters 169 name: '${uniqueString(resourceGroup().id)}sa' 170 // Non-required parameters 171 location: location 172 skuName: 'Standard_LRS' 173 diagnosticSettings: [ 174 { 175 name: 'storageAccountDiagnostics' 176 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 177 } 178 ] 179 publicNetworkAccess: 'Disabled' 180 allowBlobPublicAccess: false 181 blobServices: { 182 containers: [ 183 { 184 name: 'vmstorage' 185 publicAccess: 'None' 186 } 187 ] 188 } 189 privateEndpoints: [ 190 { 191 service: 'Blob' 192 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[1] // Private Endpoint Subnet 193 privateDnsZoneGroup: { 194 privateDnsZoneGroupConfigs: [ 195 { 196 privateDnsZoneResourceId: privateDnsBlob.outputs.resourceId 197 } 198 ] 199 } 200 } 201 ] 202 } 203} 204 205module privateDnsBlob 'br/public:avm/res/network/private-dns-zone:0.7.1' = { 206 name: '${prefix}-privatedns-blob' 207 params: { 208 name: 'privatelink.blob.${environment().suffixes.storage}' 209 location: 'global' 210 virtualNetworkLinks: [ 211 { 212 name: '${virtualNetwork.outputs.name}-vnetlink' 213 virtualNetworkResourceId: virtualNetwork.outputs.resourceId 214 } 215 ] 216 } 217} This implementation adds a dedicated subnet for Private Endpoints following the recommended practice of isolating Private Endpoints in their own subnet. The addition of just a few lines of code in the privateEndpoints parameter handles the complex tasks of creating the Private Endpoint, associating it with the VNet, and attaching it to the resource. AVM drastically simplifies the creation of Private Endpoints for just about every Azure Resource that supports them. The implementation also disables all public network connectivity to the Storage Account, ensuring it only accepts traffic via the Private Endpoint. Finally, a Private DNS zone is added and linked to the VNet, enabling the VM to resolve the Private IP address associated with the Storage Account. Bastion To securely access the Virtual Machine without exposing its SSH port to the public internet, we’ll create an Azure Bastion host. The Bastion Host requires a subnet with the exact name AzureBastionSubnet which cannot contain anything other than Bastion Hosts. ➕ Expand Code 1param location string = 'westus2' 2 3@description('Required. A password for the VM admin user.') 4@secure() 5param vmAdminPass string 6 7var addressPrefix = '10.0.0.0/16' 8var prefix = 'VM-AVM-Ex1' 9 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 11 name: 'logAnalyticsWorkspace' 12 params: { 13 // Required parameters 14 name: '${prefix}-law' 15 // Non-required parameters 16 location: location 17 } 18} 19 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = { 21 name: 'natGwPublicIpDeployment' 22 params: { 23 // Required parameters 24 name: '${prefix}-natgwpip' 25 // Non-required parameters 26 location: location 27 diagnosticSettings: [ 28 { 29 name: 'natGwPublicIpDiagnostics' 30 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 31 } 32 ] 33 } 34} 35 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = { 37 name: 'natGatewayDeployment' 38 params: { 39 // Required parameters 40 name: '${prefix}-natgw' 41 zone: 1 42 // Non-required parameters 43 publicIpResourceIds: [ 44 natGwPublicIp.outputs.resourceId 45 ] 46 } 47} 48 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 50 name: 'virtualNetworkDeployment' 51 params: { 52 // Required parameters 53 addressPrefixes: [ 54 addressPrefix 55 ] 56 name: '${prefix}-vnet' 57 // Non-required parameters 58 location: location 59 diagnosticSettings: [ 60 { 61 name: 'vNetDiagnostics' 62 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 63 } 64 ] 65 subnets: [ 66 { 67 name: 'VMSubnet' 68 addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space 69 natGatewayResourceId: natGateway.outputs.resourceId 70 networkSecurityGroupResourceId: nsgVM.outputs.resourceId 71 } 72 { 73 name: 'PrivateEndpointSubnet' 74 addressPrefix: cidrSubnet(addressPrefix, 24, 1) // second subnet in address space 75 } 76 { 77 name: 'AzureBastionSubnet' // Azure Bastion Host requires this subnet to be named exactly "AzureBastionSubnet" 78 addressPrefix: cidrSubnet(addressPrefix, 24, 2) // third subnet in address space 79 } 80 ] 81 } 82} 83 84module nsgVM 'br/public:avm/res/network/network-security-group:0.5.1' = { 85 name: 'nsgVmDeployment' 86 params: { 87 name: '${prefix}-NSG-VM' 88 location: location 89 securityRules: [ 90 { 91 name: 'AllowBastionSSH' 92 properties: { 93 access: 'Allow' 94 direction: 'Inbound' 95 priority: 100 96 protocol: 'Tcp' 97 sourceAddressPrefix: 'virtualNetwork' 98 sourcePortRange: '*' 99 destinationAddressPrefix: '*' 100 destinationPortRange: '22' 101 } 102 } 103 ] 104 } 105} 106 107module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = { 108 name: 'keyVaultDeployment' 109 params: { 110 // Required parameters 111 name: '${uniqueString(resourceGroup().id)}-kv' 112 // Non-required parameters 113 location: location 114 diagnosticSettings: [ 115 { 116 name: 'keyVaultDiagnostics' 117 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 118 } 119 ] 120 enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it 121 secrets: [ 122 { 123 name: 'vmAdminPassword' 124 value: vmAdminPass 125 } 126 ] 127 } 128} 129 130module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.14.0' = { 131 name: 'linuxVirtualMachineDeployment' 132 params: { 133 // Required parameters 134 adminUsername: 'localAdminUser' 135 adminPassword: vmAdminPass 136 imageReference: { 137 offer: '0001-com-ubuntu-server-jammy' 138 publisher: 'Canonical' 139 sku: '22_04-lts-gen2' 140 version: 'latest' 141 } 142 name: '${prefix}-vm1' 143 nicConfigurations: [ 144 { 145 ipConfigurations: [ 146 { 147 name: 'ipconfig01' 148 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet 149 } 150 ] 151 nicSuffix: '-nic-01' 152 } 153 ] 154 osDisk: { 155 caching: 'ReadWrite' 156 diskSizeGB: 128 157 managedDisk: { 158 storageAccountType: 'Standard_LRS' 159 } 160 } 161 osType: 'Linux' 162 vmSize: 'Standard_B2s_v2' 163 zone: 0 164 // Non-required parameters 165 location: location 166 } 167} 168 169module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = { 170 name: 'storageAccountDeployment' 171 params: { 172 // Required parameters 173 name: '${uniqueString(resourceGroup().id)}sa' 174 // Non-required parameters 175 location: location 176 skuName: 'Standard_LRS' 177 diagnosticSettings: [ 178 { 179 name: 'storageAccountDiagnostics' 180 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 181 } 182 ] 183 publicNetworkAccess: 'Disabled' 184 allowBlobPublicAccess: false 185 blobServices: { 186 containers: [ 187 { 188 name: 'vmstorage' 189 publicAccess: 'None' 190 } 191 ] 192 } 193 privateEndpoints: [ 194 { 195 service: 'Blob' 196 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[1] // Private Endpoint Subnet 197 privateDnsZoneGroup: { 198 privateDnsZoneGroupConfigs: [ 199 { 200 privateDnsZoneResourceId: privateDnsBlob.outputs.resourceId 201 } 202 ] 203 } 204 } 205 ] 206 } 207} 208 209module privateDnsBlob 'br/public:avm/res/network/private-dns-zone:0.7.1' = { 210 name: '${prefix}-privatedns-blob' 211 params: { 212 name: 'privatelink.blob.${environment().suffixes.storage}' 213 location: 'global' 214 virtualNetworkLinks: [ 215 { 216 name: '${virtualNetwork.outputs.name}-vnetlink' 217 virtualNetworkResourceId: virtualNetwork.outputs.resourceId 218 } 219 ] 220 } 221} 222 223// Note: Deploying a Bastion Host will automatically create a Public IP and use the subnet named "AzureBastionSubnet" 224// within our VNet. This subnet is required and must be named exactly "AzureBastionSubnet" for the Bastion Host to work. 225module bastion 'br/public:avm/res/network/bastion-host:0.6.1' = { 226 name: 'bastionDeployment' 227 params: { 228 name: '${prefix}-bastion' 229 virtualNetworkResourceId: virtualNetwork.outputs.resourceId 230 skuName: 'Basic' 231 location: location 232 diagnosticSettings: [ 233 { 234 name: 'bastionDiagnostics' 235 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 236 } 237 ] 238 } 239} This simple addition of the bastion-host AVM module completes the secure access component of our architecture. You can now access the Virtual Machine by way of the Bastion Host in the Azure Portal. Role-Based Access Control To complete our solution, we have one final task: to apply Role-Based Access Control (RBAC) restrictions on our services, namely the Key Vault and Storage Account. The goal is to explicitly allow only the Virtual Machine to have Create, Read, Update, or Delete (CRUD) permissions on these two services. This is accomplished by enabling a System-assigned Managed Identity on the Virtual Machine, then granting the VM’s Managed Identity appropriate permissions on the Storage Account and Key Vault: ➕ Expand Code 1param location string = 'westus2' 2 3@description('Required. A password for the VM admin user.') 4@secure() 5param vmAdminPass string 6 7var addressPrefix = '10.0.0.0/16' 8var prefix = 'VM-AVM-Ex1' 9 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 11 name: 'logAnalyticsWorkspace' 12 params: { 13 // Required parameters 14 name: '${prefix}-law' 15 // Non-required parameters 16 location: location 17 } 18} 19 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = { 21 name: 'natGwPublicIpDeployment' 22 params: { 23 // Required parameters 24 name: '${prefix}-natgwpip' 25 // Non-required parameters 26 location: location 27 diagnosticSettings: [ 28 { 29 name: 'natGwPublicIpDiagnostics' 30 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 31 } 32 ] 33 } 34} 35 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = { 37 name: 'natGatewayDeployment' 38 params: { 39 // Required parameters 40 name: '${prefix}-natgw' 41 zone: 1 42 // Non-required parameters 43 publicIpResourceIds: [ 44 natGwPublicIp.outputs.resourceId 45 ] 46 } 47} 48 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 50 name: 'virtualNetworkDeployment' 51 params: { 52 // Required parameters 53 addressPrefixes: [ 54 addressPrefix 55 ] 56 name: '${prefix}-vnet' 57 // Non-required parameters 58 location: location 59 diagnosticSettings: [ 60 { 61 name: 'vNetDiagnostics' 62 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 63 } 64 ] 65 subnets: [ 66 { 67 name: 'VMSubnet' 68 addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space 69 natGatewayResourceId: natGateway.outputs.resourceId 70 networkSecurityGroupResourceId: nsgVM.outputs.resourceId 71 } 72 { 73 name: 'PrivateEndpointSubnet' 74 addressPrefix: cidrSubnet(addressPrefix, 24, 1) // second subnet in address space 75 } 76 { 77 name: 'AzureBastionSubnet' // Azure Bastion Host requires this subnet to be named exactly "AzureBastionSubnet" 78 addressPrefix: cidrSubnet(addressPrefix, 24, 2) // third subnet in address space 79 } 80 ] 81 } 82} 83 84module nsgVM 'br/public:avm/res/network/network-security-group:0.5.1' = { 85 name: 'nsgVmDeployment' 86 params: { 87 name: '${prefix}-NSG-VM' 88 location: location 89 securityRules: [ 90 { 91 name: 'AllowBastionSSH' 92 properties: { 93 access: 'Allow' 94 direction: 'Inbound' 95 priority: 100 96 protocol: 'Tcp' 97 sourceAddressPrefix: 'virtualNetwork' 98 sourcePortRange: '*' 99 destinationAddressPrefix: '*' 100 destinationPortRange: '22' 101 } 102 } 103 ] 104 } 105} 106 107module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = { 108 name: 'keyVaultDeployment' 109 params: { 110 // Required parameters 111 name: '${uniqueString(resourceGroup().id)}-kv' 112 // Non-required parameters 113 location: location 114 diagnosticSettings: [ 115 { 116 name: 'keyVaultDiagnostics' 117 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 118 } 119 ] 120 enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it 121 secrets: [ 122 { 123 name: 'vmAdminPassword' 124 value: vmAdminPass 125 } 126 ] 127 roleAssignments: [ 128 { 129 principalId: virtualMachine.outputs.systemAssignedMIPrincipalId 130 principalType: 'ServicePrincipal' 131 roleDefinitionIdOrName: 'Key Vault Secrets User' // Allows read access to secrets 132 } 133 ] 134 } 135} 136 137module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.14.0' = { 138 name: 'linuxVirtualMachineDeployment' 139 params: { 140 // Required parameters 141 adminUsername: 'localAdminUser' 142 adminPassword: vmAdminPass 143 imageReference: { 144 offer: '0001-com-ubuntu-server-jammy' 145 publisher: 'Canonical' 146 sku: '22_04-lts-gen2' 147 version: 'latest' 148 } 149 name: '${prefix}-vm1' 150 nicConfigurations: [ 151 { 152 ipConfigurations: [ 153 { 154 name: 'ipconfig01' 155 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet 156 } 157 ] 158 nicSuffix: '-nic-01' 159 } 160 ] 161 osDisk: { 162 caching: 'ReadWrite' 163 diskSizeGB: 128 164 managedDisk: { 165 storageAccountType: 'Standard_LRS' 166 } 167 } 168 osType: 'Linux' 169 vmSize: 'Standard_B2s_v2' 170 zone: 0 171 // Non-required parameters 172 location: location 173 managedIdentities: { 174 systemAssigned: true 175 } 176 } 177} 178 179module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = { 180 name: 'storageAccountDeployment' 181 params: { 182 // Required parameters 183 name: '${uniqueString(resourceGroup().id)}sa' 184 // Non-required parameters 185 location: location 186 skuName: 'Standard_LRS' 187 diagnosticSettings: [ 188 { 189 name: 'storageAccountDiagnostics' 190 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 191 } 192 ] 193 publicNetworkAccess: 'Disabled' 194 allowBlobPublicAccess: false 195 blobServices: { 196 containers: [ 197 { 198 name: 'vmstorage' 199 publicAccess: 'None' 200 } 201 ] 202 roleAssignments:[ 203 { 204 principalId: virtualMachine.outputs.systemAssignedMIPrincipalId 205 principalType: 'ServicePrincipal' 206 roleDefinitionName: 'Storage Blob Data Contributor' // Allows read/write/delete on blob containers 207 } 208 ] 209 } 210 privateEndpoints: [ 211 { 212 service: 'Blob' 213 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[1] // Private Endpoint Subnet 214 privateDnsZoneGroup: { 215 privateDnsZoneGroupConfigs: [ 216 { 217 privateDnsZoneResourceId: privateDnsBlob.outputs.resourceId 218 } 219 ] 220 } 221 } 222 ] 223 } 224} 225 226module privateDnsBlob 'br/public:avm/res/network/private-dns-zone:0.7.1' = { 227 name: '${prefix}-privatedns-blob' 228 params: { 229 name: 'privatelink.blob.${environment().suffixes.storage}' 230 location: 'global' 231 virtualNetworkLinks: [ 232 { 233 name: '${virtualNetwork.outputs.name}-vnetlink' 234 virtualNetworkResourceId: virtualNetwork.outputs.resourceId 235 } 236 ] 237 } 238} 239 240// Note: Deploying a Bastion Host will automatically create a Public IP and use the subnet named "AzureBastionSubnet" 241// within our VNet. This subnet is required and must be named exactly "AzureBastionSubnet" for the Bastion Host to work. 242module bastion 'br/public:avm/res/network/bastion-host:0.6.1' = { 243 name: 'bastionDeployment' 244 params: { 245 name: '${prefix}-bastion' 246 virtualNetworkResourceId: virtualNetwork.outputs.resourceId 247 skuName: 'Basic' 248 location: location 249 diagnosticSettings: [ 250 { 251 name: 'bastionDiagnostics' 252 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 253 } 254 ] 255 } 256} Info The Azure Subscription owner will have CRUD permissions for the Storage Account but not for the Key Vault. The Key Vault requires explicit RBAC permissions assigned to a user to grant them access: Provide access to Key Vaults using RBAC. Important!: at this point, you will only be able to access the Storage Account from the Bastion Host. Remember, public internet access has been disabled! The RBAC policies have been successfully applied using a System-assigned Managed Identity on the Virtual Machine. This identity has been granted permissions on both the Key Vault and Storage Account. Now the VM can read secrets from the Key Vault and Read, Create, or Delete blobs in the Storage Account. In a real production environment, the principle of least privileged access should be applied, providing only the exact permissions each service needs to carry out its functions. Learn more about Microsoft’s recommendations for identity and access management. Conclusion In this tutorial, we’ve explored how to leverage Azure Verified Modules (AVM) to build a secure, well-architected solution in Azure. AVM modules significantly simplify the deployment of Azure resources by abstracting away much of the complexity involved in configuring individual resources. Your final, deployable Bicep template file should now look like this: ➕ Expand Code 1param location string = 'westus2' 2 3@description('Required. A password for the VM admin user.') 4@secure() 5param vmAdminPass string 6 7var addressPrefix = '10.0.0.0/16' 8var prefix = 'VM-AVM-Ex1' 9 10module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 11 name: 'logAnalyticsWorkspace' 12 params: { 13 // Required parameters 14 name: '${prefix}-law' 15 // Non-required parameters 16 location: location 17 } 18} 19 20module natGwPublicIp 'br/public:avm/res/network/public-ip-address:0.8.0' = { 21 name: 'natGwPublicIpDeployment' 22 params: { 23 // Required parameters 24 name: '${prefix}-natgwpip' 25 // Non-required parameters 26 location: location 27 diagnosticSettings: [ 28 { 29 name: 'natGwPublicIpDiagnostics' 30 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 31 } 32 ] 33 } 34} 35 36module natGateway 'br/public:avm/res/network/nat-gateway:1.2.2' = { 37 name: 'natGatewayDeployment' 38 params: { 39 // Required parameters 40 name: '${prefix}-natgw' 41 zone: 1 42 // Non-required parameters 43 publicIpResourceIds: [ 44 natGwPublicIp.outputs.resourceId 45 ] 46 } 47} 48 49module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 50 name: 'virtualNetworkDeployment' 51 params: { 52 // Required parameters 53 addressPrefixes: [ 54 addressPrefix 55 ] 56 name: '${prefix}-vnet' 57 // Non-required parameters 58 location: location 59 diagnosticSettings: [ 60 { 61 name: 'vNetDiagnostics' 62 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 63 } 64 ] 65 subnets: [ 66 { 67 name: 'VMSubnet' 68 addressPrefix: cidrSubnet(addressPrefix, 24, 0) // first subnet in address space 69 natGatewayResourceId: natGateway.outputs.resourceId 70 networkSecurityGroupResourceId: nsgVM.outputs.resourceId 71 } 72 { 73 name: 'PrivateEndpointSubnet' 74 addressPrefix: cidrSubnet(addressPrefix, 24, 1) // second subnet in address space 75 } 76 { 77 name: 'AzureBastionSubnet' // Azure Bastion Host requires this subnet to be named exactly "AzureBastionSubnet" 78 addressPrefix: cidrSubnet(addressPrefix, 24, 2) // third subnet in address space 79 } 80 ] 81 } 82} 83 84module nsgVM 'br/public:avm/res/network/network-security-group:0.5.1' = { 85 name: 'nsgVmDeployment' 86 params: { 87 name: '${prefix}-NSG-VM' 88 location: location 89 securityRules: [ 90 { 91 name: 'AllowBastionSSH' 92 properties: { 93 access: 'Allow' 94 direction: 'Inbound' 95 priority: 100 96 protocol: 'Tcp' 97 sourceAddressPrefix: 'virtualNetwork' 98 sourcePortRange: '*' 99 destinationAddressPrefix: '*' 100 destinationPortRange: '22' 101 } 102 } 103 ] 104 } 105} 106 107module keyVault 'br/public:avm/res/key-vault/vault:0.12.1' = { 108 name: 'keyVaultDeployment' 109 params: { 110 // Required parameters 111 name: '${uniqueString(resourceGroup().id)}-kv' 112 // Non-required parameters 113 location: location 114 diagnosticSettings: [ 115 { 116 name: 'keyVaultDiagnostics' 117 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 118 } 119 ] 120 enablePurgeProtection: false // disable purge protection for this example so we can more easily delete it 121 secrets: [ 122 { 123 name: 'vmAdminPassword' 124 value: vmAdminPass 125 } 126 ] 127 roleAssignments: [ 128 { 129 principalId: virtualMachine.outputs.systemAssignedMIPrincipalId 130 principalType: 'ServicePrincipal' 131 roleDefinitionIdOrName: 'Key Vault Secrets User' // Allows read access to secrets 132 } 133 ] 134 } 135} 136 137module virtualMachine 'br/public:avm/res/compute/virtual-machine:0.14.0' = { 138 name: 'linuxVirtualMachineDeployment' 139 params: { 140 // Required parameters 141 adminUsername: 'localAdminUser' 142 adminPassword: vmAdminPass 143 imageReference: { 144 offer: '0001-com-ubuntu-server-jammy' 145 publisher: 'Canonical' 146 sku: '22_04-lts-gen2' 147 version: 'latest' 148 } 149 name: '${prefix}-vm1' 150 nicConfigurations: [ 151 { 152 ipConfigurations: [ 153 { 154 name: 'ipconfig01' 155 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[0] // VMSubnet 156 } 157 ] 158 nicSuffix: '-nic-01' 159 } 160 ] 161 osDisk: { 162 caching: 'ReadWrite' 163 diskSizeGB: 128 164 managedDisk: { 165 storageAccountType: 'Standard_LRS' 166 } 167 } 168 osType: 'Linux' 169 vmSize: 'Standard_B2s_v2' 170 zone: 0 171 // Non-required parameters 172 location: location 173 managedIdentities: { 174 systemAssigned: true 175 } 176 } 177} 178 179module storageAccount 'br/public:avm/res/storage/storage-account:0.19.0' = { 180 name: 'storageAccountDeployment' 181 params: { 182 // Required parameters 183 name: '${uniqueString(resourceGroup().id)}sa' 184 // Non-required parameters 185 location: location 186 skuName: 'Standard_LRS' 187 diagnosticSettings: [ 188 { 189 name: 'storageAccountDiagnostics' 190 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 191 } 192 ] 193 publicNetworkAccess: 'Disabled' 194 allowBlobPublicAccess: false 195 blobServices: { 196 containers: [ 197 { 198 name: 'vmstorage' 199 publicAccess: 'None' 200 } 201 ] 202 roleAssignments:[ 203 { 204 principalId: virtualMachine.outputs.systemAssignedMIPrincipalId 205 principalType: 'ServicePrincipal' 206 roleDefinitionName: 'Storage Blob Data Contributor' // Allows read/write/delete on blob containers 207 } 208 ] 209 } 210 privateEndpoints: [ 211 { 212 service: 'Blob' 213 subnetResourceId: virtualNetwork.outputs.subnetResourceIds[1] // Private Endpoint Subnet 214 privateDnsZoneGroup: { 215 privateDnsZoneGroupConfigs: [ 216 { 217 privateDnsZoneResourceId: privateDnsBlob.outputs.resourceId 218 } 219 ] 220 } 221 } 222 ] 223 } 224} 225 226module privateDnsBlob 'br/public:avm/res/network/private-dns-zone:0.7.1' = { 227 name: '${prefix}-privatedns-blob' 228 params: { 229 name: 'privatelink.blob.${environment().suffixes.storage}' 230 location: 'global' 231 virtualNetworkLinks: [ 232 { 233 name: '${virtualNetwork.outputs.name}-vnetlink' 234 virtualNetworkResourceId: virtualNetwork.outputs.resourceId 235 } 236 ] 237 } 238} 239 240// Note: Deploying a Bastion Host will automatically create a Public IP and use the subnet named "AzureBastionSubnet" 241// within our VNet. This subnet is required and must be named exactly "AzureBastionSubnet" for the Bastion Host to work. 242module bastion 'br/public:avm/res/network/bastion-host:0.6.1' = { 243 name: 'bastionDeployment' 244 params: { 245 name: '${prefix}-bastion' 246 virtualNetworkResourceId: virtualNetwork.outputs.resourceId 247 skuName: 'Basic' 248 location: location 249 diagnosticSettings: [ 250 { 251 name: 'bastionDiagnostics' 252 workspaceResourceId: logAnalyticsWorkspace.outputs.resourceId 253 } 254 ] 255 } 256} AVM modules provide several key advantages over writing raw Bicep templates: Simplified Resource Configuration: AVM modules handle much of the complex configuration work behind the scenes Built-in Recommended Practices: The modules implement many of Microsoft’s recommended practices by default Consistent Outputs: Each module exposes a consistent set of outputs that can be easily referenced Reduced Boilerplate Code: What would normally require hundreds of lines of Bicep code can be accomplished in a fraction of the space As you continue your journey with Azure and AVM, remember that this approach can be applied to more complex architectures as well. The modular nature of AVM allows you to mix and match components to build solutions that meet your specific needs while adhering to Microsoft’s Well-Architected Framework. By using AVM modules as building blocks, you can focus more on your solution architecture and less on the intricacies of individual resource configurations, ultimately leading to faster development cycles and more reliable deployments. Clean up your environment When you are ready, you can remove the infrastructure deployed in this example. Key Vaults are set to a soft-delete state so you will also need to purge the one we created in order to fully delete it. The following commands will remove all resources created by your deployment: Clean up with PowerShell AZ CLI # Delete the resource group Remove-AzResourceGroup -Name "avm-bicep-vmexample1" -Force # Purge the Key Vault Remove-AzKeyVault -VaultName "<keyVaultName>" -Location "<location>" -InRemovedState -Force # Delete the resource group az group delete --name 'avm-bicep-vmexample1' --yes --no-wait # Purge the Key Vault az keyvault purge --name '<keyVaultName>' --no-wait Congratulations, you have successfully leveraged AVM Bicep modules to deploy resources in Azure! Tip We welcome your contributions and feedback to help us improve the AVM modules and the overall experience for the community! --- Source: https://raw.githubusercontent.com/Azure/Azure-Verified-Modules/refs/heads/main/docs/content/usage/solution-development/bicep.md Last Modified: 0001-01-01