5 best practices for using Azure Bicep
Azure Bicep (Bicep for short) hasn’t been around very long compared to other Infrastructure as Code tooling. In August 2020, Bicep was introduced as alpha release. Since Microsoft promoted Bicep during Ignite 2020, it has gained a ton of popularity. And now, with already over 30+ releases, many infrastructure engineers have turned to Bicep for deploying their Azure Resources, and for good reason. Digging in big ARM templates isn’t one of the funniest tasks to do, is it?
Now, you might have a ton of experience with Bicep or you are just starting as a beginner using Bicep. You’ve already searched the internet to boost your knowledge, but no matter where you are, you’re probably looking to apply some best practices. Well… in this blog, you’ll learn 5 best practices for using Azure Bicep:
- Prepare for team collaboration
- Build your development environment with bootstrapping
- Understand the basis of rapid feedback loops
- How to share Bicep using modules and template specs
- Who said writing documentation is boring? Let’s automate!
Grab your coffee, buckle your seatbelt and enjoy the learning journey of using best practices for Azure Bicep!
Prepare for team collaboration
Bicep supports the ability to specify parameters, making it more reusable amongst your team members. Parameters also can control the behavior of a deployment. With the combination of using parameters and modules, you can easily improve the readability of your Bicep files by removing the complex details of your deployment. The beauty of parameters is that they also support decorators. Decorators simply describe the parameter and even define constraints for values that are allowed to be passed in. Armed with this knowledge, how can you prepare the structure of your precious Bicep files? Let’s have a quick look.

The src folder has all your Bicep files for each product that you want to deploy. In this example, it’s the Azure Automation account you want to deploy. Inside the modules folder are the Bicep files that will be shared for deployment. It references the local resource file as shown below:
var templateSpecName = 'automationaccount'
var version = '0.0.1'
var releaseNotes = 'Template to deploy automation account'
@description('Specify the automation account name')
param automationAccountName string
@description('Location of resource group')
param location string = resourceGroup().location
@maxLength(24)
param azureAutomationAccountName string = 'aa-${automationAccountName}'
@description('Deployment module of azure automation account')
module deployment_azure_automation_account '../resources/resource.automationaccount.bicep' = {
name: 'module.automationaccount'
params: {
azureAutomationAccountName: toLower(azureAutomationAccountName)
location: location
}
}
You already notice three things in the above code:
- It follows the Azure best practices of naming convention.
- You can always adjust the azureAutomationAccountName parameter if you have an internal naming convention
- Variables are declared at the top to specify some metadata which is going to be used later.
- The modules and resources folder uses abstracting for complex resource instance definitions which helps improving the readability in a Bicep template.
- When a Bicep module is compiled, it is added as nested template in ARM JSON
Moving to the resource.automationaccount.bicep file, where you’ll specify the actual resource that you want to be deployed.
@description('Location of resource group')
param location string
@description('Name of the automation account')
param azureAutomationAccountName string
resource automationAccount 'Microsoft.Automation/automationAccounts@2021-06-22' = {
name: azureAutomationAccountName
location: location
properties: {
sku: {
name: 'Free'
}
}
}
Awesome, you’ve already got your project structure setup. But what about newcomers to your project? You already have your software installed locally, like Visual Studio Code, the Bicep extension and the Azure CLI. Let’s help them out with bootstrapping your environment.
Build your development environment with bootstrapping
If you let your newcomers edit Bicep files in a simple editor, then let them grab the computer and throw it out of the window. As already mentioned, Visual Studio Code is an intelligent editor in combination with the Bicep extension. It provides IntelliSense and code snippets to get you started right away. You can instruct your fellow team members to install it, but wouldn’t it be beautiful if they can bootstrap the environment with the required software? Bootstrapping is a technique to simply build up your development environment. This can be simply a script or it can become as complex as you want it.
Let’s introduce a new file called bootstrap.ps1 in the root of the repository that can be called with a few simple parameters. The project requires two PowerShell modules to be installed, which you also want to be installed for each user. Both will come back later, but you can add requirements.psd1 also in the root including the code shown below:
@{
PSDependOptions = @{
Target = 'CurrentUser'
}
InvokeBuild = @{
Version = '5.9.10'
}
az = @{
MinimumVersion = 'Latest'
}
}
The following code is added to the bootstrap script and installs the required PowerShell modules, Visual Studio Code and Azure CLI that calls Bicep.
[CmdletBinding()]
Param
(
# Bootstrap PS Modules
[switch]$Bootstrap,
# Bootstrap VSCode
[switch]$InstallVSCode,
# Bootstrap Azure CLI
[switch]$InstallAzureCLI,
# Visual Studio Code installation
[parameter()]
[ValidateSet(, "64-bit", "32-bit")]
[string]$Architecture = "64-bit",
[parameter()]
[ValidateSet("stable", "insider")]
[string]$BuildEdition = "stable",
[Parameter()]
[ValidateNotNull()]
[string[]]$AdditionalExtensions = @()
)
$ErrorActionPreference = 'Stop'
if ($Bootstrap.IsPresent) {
Get-PackageProvider -Name Nuget -ForceBootstrap | Out-Null
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
if (-not (Get-Module -Name PSDepend -ListAvailable)) {
Install-Module -Name PSDepend -Repository PSGallery
}
Import-Module -Name PSDepend -Verbose:$false
Invoke-PSDepend -Path './requirements.psd1' -Install -Import -Force -WarningAction SilentlyContinue
}
if ($InstallVSCode.IsPresent)
{
if (($PSVersionTable.PSVersion.Major -le 5) -or $IsWindows)
{
switch ($Architecture)
{
"64-bit"
{
if ((Get-CimInstance -ClassName Win32_OperatingSystem).OSArchitecture -eq "64-bit")
{
$codePath = $env:ProgramFiles
$bitVersion = "win32-x64"
}
else
{
$codePath = $env:ProgramFiles
$bitVersion = "win32"
$Architecture = "32-bit"
}
break;
}
"32-bit"
{
if ((Get-CimInstance -ClassName Win32_OperatingSystem).OSArchitecture -eq "32-bit")
{
$codePath = $env:ProgramFiles
$bitVersion = "win32"
}
else
{
$codePath = ${env:ProgramFiles(x86)}
$bitVersion = "win32"
}
break;
}
}
switch ($BuildEdition)
{
"Stable"
{
$codeCmdPath = "$codePath\Microsoft VS Code\bin\code.cmd"
$appName = "Visual Studio Code ($($Architecture))"
break;
}
"Insider"
{
$codeCmdPath = "$codePath\Microsoft VS Code Insiders\bin\code-insiders.cmd"
$appName = "Visual Studio Code - Insiders Edition ($($Architecture))"
break;
}
}
try
{
$ProgressPreference = 'SilentlyContinue'
if (!(Test-Path $codeCmdPath))
{
Write-Host "`nDownloading latest $appName..." -ForegroundColor Yellow
Remove-Item -Force "$env:TEMP\vscode-$($BuildEdition).exe" -ErrorAction Stop
Invoke-WebRequest -Uri "https://update.code.visualstudio.com/latest/$($bitVersion)/$($BuildEdition)" -OutFile "$env:TEMP\vscode-$($BuildEdition).exe"
Write-Host "`nInstalling $appName..." -ForegroundColor Yellow
Start-Process -Wait "$env:TEMP\vscode-$($BuildEdition).exe" -ArgumentList /silent, /mergetasks=!runcode
}
else
{
Write-Host "`n$appName is already installed." -ForegroundColor Yellow
}
$extensions = @("ms-azuretools.vscode-bicep") + $AdditionalExtensions
foreach ($extension in $extensions)
{
Write-Host "`nInstalling extension $extension..." -ForegroundColor Yellow
& $codeCmdPath --install-extension $extension
}
Write-Host "`nInstallation complete!`n`n" -ForegroundColor Green
}
finally
{
$ProgressPreference = 'Continue'
}
}
else
{
Write-Error "This script is currently only supported on the Windows operating system."
}
}
if ($InstallAzureCLI.IsPresent)
{
Write-Output “Installing Azure CLI”
Invoke-WebRequest -Uri https://aka.ms/installazurecliwindows -OutFile .\AzureCLI.msi
Start-Process msiexec.exe -Wait -ArgumentList '/I AzureCLI.msi /quiet'
rm .\AzureCLI.msi
}
You can now simply add a README.md file in the repository, instructing your team members to call the bootstrap script with the required parameters. Such an example would look like:
.\bootstrap.ps1 -Bootstrap -InstallVsCode -InstallAzureCLI
This will get your newcomers bootstrapped instantly to get started and write some useful code. You have done a great job!
Understand the basics of rapid feedback loops
Most teams relay on CI/CD systems to provide feedback. While it is easy to leverage, it means that your code is already in the cloud. What if it is already possible to do some pre-checks before you commit your code and get feedback on the building process locally? Is it possible to do some testing before and build that quality in? Let’s see how you can introduce a feedback loop before sending it to your version control system with a build and test automation tool.
3 advantages using a build automation script
Invoke-Build is a build and test automation tool which invokes task defined in PowerShell. It makes it possible to effectively process inputs and outputs. Now, it is a legitimate question why you would need such a tool. There are advantages over using a build automation script:
- it’s much easier to invoke in a project or workspace directory;
- the current location is known, making the paths relative;
- task may have relations with each other.
With that knowledge, you can add the build script to the root of your project. Invoke-Build searches by default for .build.ps1, so how would such a script look ?
To build Bicep files and output it to JSON, you will have to run az bicep build. In the build script, you can simply search for all the Bicep files and build them accordingly. The following code defines two parameters with relative paths in it. The $BuildRoot variable sets the directory to the current location. The Clean task is responsible for cleaning all the output directories to keep the workspace clean, just like running Clean in a Visual Studio project. Lastly, the BuildBicep task picks up all the Bicep files and runs it against the az bicep build command.
[CmdletBinding()]
Param (
[Parameter(Mandatory = $False, HelpMessage='Specify the source directory to retrieve modules')]
[string]$TemplatePath = "$BuildRoot\src",
[Parameter(Mandatory = $False, HelpMessage='Specify the output directory to build ARM template')]
[string]$BuildDirectory = "$BuildRoot\Build"
)
task Clean {
Remove-Item -Path $BuildDirectory -Force -ErrorAction SilentlyContinue -Recurse
}
task BuildBicep {
Write-Build Yellow "Retrieving all Bicep file(s) from: $TemplatePath"
$Templates = (Get-ChildItem -Path $TemplatePath -Recurse -Include *.bicep)
foreach ($Template in $Templates) {
Write-Build Yellow "Building bicep: $($Template.FullName)"
if (-not (Test-Path $BuildDirectory)) {
New-Item -Path $BuildDirectory -ItemType Directory -Force
}
az bicep build --file $Template.FullName --outdir $BuildDirectory
$PackagePath = "$BuildDirectory\$($Template.Name.Replace('.bicep', '.json'))".ToString()
$script:PackageLocation += $PackagePath
Write-Build Yellow "Output: $PackagePath"
}
}
task . Clean, BuildBicep
When bootstrapping the project, the Invoke-Build module should already have been installed for you or your team members. In a PowerShell terminal, running Invoke-Build cmdlet runs both task.

But building, is that all the feedback you can get? No silly, Microsoft introduced the Azure Resource Manager Template Toolkit (arm-ttk) to check a template for a set of coding best practices.
Validating templates with Azure Resource Manager Template Toolkit
The ARM-TTK was used to validate templates for the Azure Marketplace. The purpose is to ensure a consistent set of coding practices, making it easier to develop. Sounds good, right? Adding the ARM-TTK to our toolbelt helps us improve the coding practices to be followed by you, but also your newcomers. The following code will download the ARM-TTK and run against all modules produced in the build output.
task TestBicep {
if (-not (Test-Path $TestDirectory)) {
New-Item -Path $TestDirectory -ItemType Directory -Force
}
$Packages = (Get-ChildItem -Path $BuildDirectory -Filter *.json | Where-Object {$_.Name -like "module.*"}).FullName
Write-Build Yellow "Retrieved number of modules to test: $($Packages.Count)"
$ModulePath = Get-ChildItem -Path $TestDirectory -Filter arm-ttk.psm1 -Recurse
if (-not $ModulePath) {
Write-Build Yellow "ARM Test Toolkit was not found, downloading... "
$ARMTTKUrl = 'https://azurequickstartsservice.blob.core.windows.net/ttk/latest/arm-template-toolkit.zip'
$DestinationDirectory = $TestDirectory + (Split-Path -Path $ARMTTKUrl -Leaf)
try {
Write-Build Yellow "Downloading to: $DestinationDirectory"
Invoke-RestMethod -Uri $ARMTTKUrl -OutFile $DestinationDirectory
}
catch {
Throw "Exception occured: $_"
}
Write-Build Yellow "Extracting ARM Test Toolkit to: $TestDirectory"
Expand-Archive -Path $DestinationDirectory -DestinationPath $TestDirectory
$ModulePath = (Get-ChildItem -Path $TestDirectory -Filter arm-ttk.psm1 -Recurse).FullName
}
Import-Module $ModulePath
foreach ($Package in $Packages) {
Write-Build Yellow "Testing against: $Package"
$Result = Test-AzTemplate -TemplatePath $Package -ErrorAction SilentlyContinue
Write-Output $Result.Name
}
}
At the top of the build script, an extra parameter is added to output the result.
[Parameter(Mandatory = $False, HelpMessage='Specify the output directory to test ARM template')]
[string]$TestDirectory = "$BuildRoot\testResults\"
You can now run the Invoke-Build cmdlet with the TestBicep task and see the output of rules that are being checked.

Check for syntax and best practice violations with Bicep linter
To have one more additional check when building your Bicep files, you can use the Bicep linter. The Bicep linter helps enforcing coding standards when developing Bicep code. To help your team members, you can add bicepconfig.json in your repository and customize it to your own needs. The following rule checks for unused variables, which will be triggered.

You can find the full list of rules that can be set in the current Bicep version here.
You now have a set of coding standards, building the quality in (and getting it out) and rapidly retrieving feedback. It is time to share your modules.
How to share Bicep using modules and template specs
You might already have guessed, with the variables defined in the module, how you can share your Bicep modules. For this, you’re going to use a template spec which is supported for ARM. Using a template spec allows you to share ARM templates with other users in your organization. Just like any Azure resource, it gives you the ability to use RBAC roles to share the template spec. Again, you want to make it as easy as possible to distribute your modules. Therefore, at the top of the build script, let us introduce the resource group and location to deploy the template specs against.
[Parameter(Mandatory = $False, HelpMessage='Specify the resource group to publish and deploy')]
[string]$ResourceGroupName,
[Parameter(Mandatory = $False, HelpMessage='Specify the location of the rescoure group')]
[string]$Location = 'westeurope'
Beneath the TestBicep task, you can add the PublishBicep task with the following code.
task PublishBicep {
$Script:Templates = [System.Collections.ArrayList]@()
$Packages = (Get-ChildItem -Path $BuildDirectory -Filter *.json | Where-Object {$_.Name -like "module.*"}).FullName
Write-Build Yellow "Retrieved number of packages to publish: $($Packages.Count)"
foreach ($Package in $Packages) {
Write-Build Yellow "Retrieving content from: $Package"
$JSONContent = Get-Content $Package | ConvertFrom-Json
if ($JSONContent.variables.templateSpecName) {
$TemplateObject = [PSCustomObject]@{
TemplateFileName = $Package
TemplateSpecName = $JSONContent.variables.templateSpecName
Version = $JSONContent.variables.version
Description = $JSONContent.variables.releasenotes
}
Write-Build Yellow $TemplateObject
$null = $Templates.Add($TemplateObject)
}
}
$Templates.ToArray() | Foreach-Object {
$_
$TemplateSpecName = $_.TemplateSpecName
try {
$Params = @{
ResourceGroupName = $ResourceGroupName
Name = $TemplateSpecName
ErrorAction = 'Stop'
}
$ExistingSpec = Get-AzTemplateSpec @Params
$CurrentVersion = $ExistingSpec.Versions | Sort-Object name | Select-Object -Last 1 -ExpandProperty Name
} catch {
Write-Build Yellow "No version exist for template: $TemplateSpecName"
}
if ($_.Version -gt $CurrentVersion) {
Write-Build Yellow "Template version is newer than in Azure, deploying..."
try {
$SpecParameters = @{
Name = $TemplateSpecName
ResourceGroupName = $ResourceGroupName
Location = $Location
TemplateFile = $_.TemplateFileName
Version = $_.Version
VersionDescription = $_.Description
}
$null = New-AzTemplateSpec @SpecParameters
Write-Build Yellow "Setting new version number"
$Version = $_.Version
} catch {
$Version = $CurrentVersion
Write-Error "Something went wrong with deploying $TemplateSpecName : $_"
}
} else {
Write-Build Yellow "$TemplateSpecName template is up to date"
Write-Build Yellow "Keeping current version number"
$Version = $CurrentVersion
}
}
}
You might already have spotted it, but in the beginning section you can see where these variables come in handy to define release notes and the version of the template.
if ($JSONContent.variables.templateSpecName) {
$TemplateObject = [PSCustomObject]@{
TemplateFileName = $Package
TemplateSpecName = $JSONContent.variables.templateSpecName
Version = $JSONContent.variables.version
Description = $JSONContent.variables.releasenotes
}
Write-Build Yellow $TemplateObject
$null = $Templates.Add($TemplateObject)
}
To run the publishing, you of course need to be connected with the Connect-AzAccount. If you are following along, you can execute the task and specify your own resource group after authenticating against Azure.

In the Azure Portal, browsing to the template spec will show you the notes you have added.

It is now possible for you to provide the required access to the resource group and provide instructions to your team members on how to use the template itself. Oh no wait, you have not documented it! Let’s make our lives easier and add that also in the script.
Who said writing documentation is boring? Let’s automate!
Documentation is always the task that is heavily overlooked. When you are developing a solution, you are always happy that it is finished and suddenly you think: “what about documentation?”. You open your editor again, make some documentation and eventually you will be glad you did it. What if you can make it fun and easy?
You have already defined the parameter decorators in your Bicep files, you have specified some default values and made some notes with it. It would be a burdensome to open all the files, extract that content, place it in some markdown file and commit it. Let’s make it a bit more fun. In the build script, you’ll define three more parameters to output your precious documentation.
[Parameter(Mandatory = $False, HelpMessage='Specify the template output folder to build documentation')]
[string]$DocsDirectory = "$BuildRoot\docs",
$ExcludeFolders = '',
[bool]$KeepStructure = $false,
[bool]$IncludeWikiTOC = $false
The following task will be responsible for generating the markdown files and create a docs folder.
task GenerateDocs {
Write-Build Yellow ("TemplateFolder : $($BuildDirectory)")
Write-Build Yellow ("OutputFolder : $($DocsDirectory)")
Write-Build Yellow ("ExcludeFolders : $($ExcludeFolders)")
Write-Build Yellow ("KeepStructure : $($KeepStructure)")
Write-Build Yellow ("IncludeWikiTOC : $($IncludeWikiTOC)")
$templateNameSuffix = ".md"
$option = [System.StringSplitOptions]::RemoveEmptyEntries
$exclude = $ExcludeFolders.Split(',', $option)
try {
Write-Build Yellow "Starting documentation generation for folder $($BuildDirectory)"
if (!(Test-Path $DocsDirectory)) {
Write-Build Yellow "Output path does not exists creating the folder: $($DocsDirectory)"
New-Item -ItemType Directory -Force -Path $DocsDirectory
}
# Get the scripts from the folder
$templates = Get-Childitem $BuildDirectory -Filter "*.json" -Recurse -Exclude "*parameters.json","*descriptions.json","*parameters.local.json"
foreach ($template in $templates) {
if (!$exclude.Contains($template.Directory.Name)) {
Write-Build Yellow "Documenting file: $($template.FullName)"
if ($KeepStructure) {
if ($template.DirectoryName -ne $TemplateFolder) {
$newfolder = $DocsDirectory + "/" + $template.Directory.Name
if (!(Test-Path $newfolder)) {
Write-Build Yellow "Output folder for item does not exists creating the folder: $($newfolder)"
New-Item -Path $DocsDirectory -Name $template.Directory.Name -ItemType "directory"
}
}
} else {
$newfolder = $DocsDirectory
}
$templateContent = Get-Content $template.FullName -Raw -ErrorAction Stop
$templateObject = ConvertFrom-Json $templateContent -ErrorAction Stop
if (!$templateObject) {
Write-Error -Message ("ARM Template file is not a valid json, please review the template")
} else {
$outputFile = ("$($newfolder)/$($template.BaseName)$($templateNameSuffix)")
Out-File -FilePath $outputFile
if ($IncludeWikiTOC) {
("[[_TOC_]]`n") | Out-File -FilePath $outputFile
"`n" | Out-File -FilePath $outputFile -Append
}
if ((($templateObject | get-member).name) -match "metadata") {
if ((($templateObject.metadata | get-member).name) -match "Description") {
Write-Build Yellow "Description found. Adding to parent page and top of the arm-template specific page"
("## Description") | Out-File -FilePath $outputFile -Append
$templateObject.metadata.Description | Out-File -FilePath $outputFile -Append
}
("## Information") | Out-File -FilePath $outputFile -Append
$metadataProperties = $templateObject.metadata | get-member | where-object MemberType -eq NoteProperty
foreach ($metadata in $metadataProperties.Name) {
switch ($metadata) {
"Description" {
Write-Build Yellow ("already processed the description. skipping")
}
Default {
("`n") | Out-File -FilePath $outputFile -Append
("**$($metadata):** $($templateObject.metadata.$metadata)") | Out-File -FilePath $outputFile -Append
}
}
}
}
("## Parameters") | Out-File -FilePath $outputFile -Append
# Create a Parameter List Table
$parameterHeader = "| Parameter Name | Parameter Type |Parameter Description | Parameter DefaultValue | Parameter AllowedValues |"
$parameterHeaderDivider = "| --- | --- | --- | --- | --- | "
$parameterRow = " | {0}| {1} | {2} | {3} | {4} |"
$StringBuilderParameter = @()
$StringBuilderParameter += $parameterHeader
$StringBuilderParameter += $parameterHeaderDivider
$StringBuilderParameter += $templateObject.parameters | get-member -MemberType NoteProperty | ForEach-Object { $parameterRow -f $_.Name , $templateObject.parameters.($_.Name).type , $templateObject.parameters.($_.Name).metadata.description, $templateObject.parameters.($_.Name).defaultValue , (($templateObject.parameters.($_.Name).allowedValues) -join ',' )}
$StringBuilderParameter | Out-File -FilePath $outputFile -Append
("## Resources") | Out-File -FilePath $outputFile -Append
# Create a Resource List Table
$resourceHeader = "| Resource Name | Resource Type | Resource Comment |"
$resourceHeaderDivider = "| --- | --- | --- | "
$resourceRow = " | {0}| {1} | {2} | "
$StringBuilderResource = @()
$StringBuilderResource += $resourceHeader
$StringBuilderResource += $resourceHeaderDivider
$StringBuilderResource += $templateObject.resources | ForEach-Object { $resourceRow -f $_.Name, $_.Type, $_.Comments }
$StringBuilderResource | Out-File -FilePath $outputFile -Append
if ((($templateObject | get-member).name) -match "outputs") {
Write-Build Yellow "Output objects found."
if (Get-Member -InputObject $templateObject.outputs -MemberType 'NoteProperty') {
("## Outputs") | Out-File -FilePath $outputFile -Append
# Create an Output List Table
$outputHeader = "| Output Name | Output Type | Output Value |"
$outputHeaderDivider = "| --- | --- | --- | "
$outputRow = " | {0}| {1} | {2} | "
$StringBuilderOutput = @()
$StringBuilderOutput += $outputHeader
$StringBuilderOutput += $outputHeaderDivider
$StringBuilderOutput += $templateObject.outputs | get-member -MemberType NoteProperty | ForEach-Object { $outputRow -f $_.Name , $templateObject.outputs.($_.Name).type , $templateObject.outputs.($_.Name).value }
$StringBuilderOutput | Out-File -FilePath $outputFile -Append
}
} else {
Write-Build Yellow "This file does not contain outputs"
}
}
}
}
} catch {
Write-Error "Something went wrong while generating the output documentation: $_"
}
}
Let’s just run that code and see how the results will be dropped in.

When opening the file in the docs folder, the following file will be produced by the task.

Pure sweetness, wouldn’t you say?
Conclusion
You have learned best practices for using Azure Bicep You have prepared the local development environment, made it extremely easy to on-board newcomers, providing some coding standards around the project that provide rapid feedback. Also, the repository structure speaks for itself, and documentation is included, giving your team members the instructions on how the template specifications can be deployed.
This blog post is part of a larger series. Stay tuned for more on applying best practices for using Azure Bicep!