Best Practices for Writing Clean, Maintainable, and Efficient Bicep Code

With the increasing adoption of cloud services, infrastructure-as-code (IaC) tools like Bicep have become essential for automating and managing resources in Microsoft Azure. Bicep, a domain-specific language (DSL) for deploying Azure resources, offers a simpler syntax compared to ARM templates, making it easier to write and manage infrastructure configurations. However, like any codebase, Bicep can become hard to maintain and inefficient without careful attention to best practices.

In this blog post, we’ll explore some fundamental best practices for writing clean, maintainable, and efficient Bicep code that scales well over time.

Modularize Your Code

Having large, monolithic Bicep files can quickly become unwieldy and challenging to manage. By breaking your code into smaller, reusable modules, you can improve readability, maintainability, and reusability.

How to implement it:

  • Create reusable modules: Separate logical sections of your infrastructure into smaller, reusable modules. For instance, if your application needs a virtual network (VNet), a storage account, and an Azure SQL database, each resource can have its own module.
  • Use parameterized modules: Ensure that modules accept parameters, which increases flexibility and reuse.

Example vnet.bicep module:

@description('The name of the virtual network')
param vnetName string

@description('The address space for the virtual network')
param addressPrefix string

@description('The name of the first subnet')
param subnet1Name string = ''

@description('The address prefix of the first subnet')
param subnet1Prefix string = ''

@description('The name of the second subnet (optional)')
param subnet2Name string = ''

@description('The address prefix of the second subnet (optional)')
param subnet2Prefix string = ''

@description('The location where the virtual network will be deployed')
param location string = resourceGroup().location

resource vnet 'Microsoft.Network/virtualNetworks@2022-09-01' = {
  name: vnetName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [
        addressPrefix
      ]
    }
    subnets: [
      {
        name: subnet1Name
        properties: {
          addressPrefix: subnet1Prefix
        }
      }
      // Conditionally add the second subnet only if it's specified
      if (subnet2Name != '' && subnet2Prefix != '') {
        name: subnet2Name
        properties: {
          addressPrefix: subnet2Prefix
        }
      }
    ]
  }
}

output vnetId string = vnet.id
output vnetAddressSpace string = vnet.properties.addressSpace.addressPrefixes[0]
module vnet './network/vnet.bicep' = {
  name: 'vnetDeployment'
  params: {
    vnetName: 'myVNet'
    addressPrefix: '10.0.0.0/16'
    subnet1Name: 'subnet1'
    subnet1Prefix: '10.0.1.0/24'
    subnet2Name: 'subnet2'
    subnet2Prefix: '10.0.2.0/24'
  }
}

Leverage Parameters and Variables for Flexibility

Why it’s important: Hardcoding values directly into your Bicep files makes your code rigid and prone to errors when scaling. Parameters and variables help abstract away values that are likely to change between environments (dev, test, production).

How to implement it:

  • Use parameters for inputs: Define parameters for values that will change depending on the environment or configuration. This makes your Bicep files adaptable and reusable.
  • Use variables for intermediate values: If you need to calculate or reuse certain values within the template, define them as variables to avoid duplication.

Example:

param environment string = 'dev'

var storageAccountName = 'sa${environment}${uniqueString(resourceGroup().id)}'

Follow Naming Conventions

Why it’s important: Consistent naming conventions improve readability, make troubleshooting easier, and help you understand resource purpose at a glance. This is especially important when working in large teams or across multiple projects.

How to implement it:

  • Use PascalCase for resource names: Resources like modules, variables, and outputs should follow a consistent naming style.
  • Prefix or suffix with resource type: Including the resource type in the name helps differentiate resources quickly.

Example:

resource myStorageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' = {
  name: storageAccountName
  location: resourceGroup().location
  sku: {
    name: 'Standard_LRS'
  }
}

Use the Latest API Versions

Why it’s important: Azure continuously evolves, adding new features, deprecating old ones, and optimizing the performance of services. Using outdated API versions may limit functionality or introduce unnecessary complexity.

How to implement it:

  • Always check for the latest stable API version for each resource type.
  • Use tools like az bicep decompile to convert ARM templates to Bicep, which often suggest newer API versions.

Example:

resource myAppService 'Microsoft.Web/sites@2022-03-01' = {
  name: 'myAppService'
  location: resourceGroup().location
  properties: {
    serverFarmId: appServicePlan.id
  }
}

Take Advantage of Loops and Conditions

Why it’s important: Instead of duplicating similar resources, loops and conditions allow you to deploy multiple resources efficiently and conditionally based on parameters or variables. This improves code maintainability and reduces redundancy.

How to implement it:

  • Loops: Use for expressions to iterate over an array of items or ranges to deploy resources.
  • Conditions: Use if statements to conditionally deploy resources based on a given condition.

Example of a loop:

param subnetNames array = ['subnet1', 'subnet2', 'subnet3']

resource vnetSubnets 'Microsoft.Network/virtualNetworks/subnets@2022-09-01' = [for subnetName in subnetNames: {
  name: subnetName
  properties: {
    addressPrefix: '10.0.${subnetNames.indexOf(subnetName)}.0/24'
  }
}]

Example of a condition:

param enableDiagnostics bool = true

resource diagnosticsSetting 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagnostics) {
  name: 'diagSetting'
  properties: {
    storageAccountId: storageAccount.id
  }
}

Utilize Output Statements

Why it’s important: Output statements are crucial when chaining multiple Bicep files together. They allow you to pass information (e.g., resource IDs or connection strings) from one module to another, ensuring smooth orchestration between deployments.

How to implement it:

  • Define outputs for key values like resource IDs, connection strings, or keys, which may be needed by other Bicep files or post-deployment scripts.

Example:

output storageAccountId string = myStorageAccount.id
output appServiceUrl string = myAppService.defaultHostName

Test Your Bicep Code Regularly

Why it’s important: Continuous testing ensures that your infrastructure-as-code behaves as expected and that any changes do not break the deployment. It’s especially critical as your codebase grows in complexity.

How to implement it:

  • Use Bicep linting tools (like bicep build) to catch syntax errors early.
  • Integrate your Bicep code into a CI/CD pipeline with automated deployment and testing to catch issues before production.

Example: Use GitHub Actions or Azure DevOps Pipelines to run tests and deploy your Bicep templates.

steps:
- script: |
    az bicep build --file main.bicep
    az deployment group validate --resource-group myResourceGroup --template-file main.bicep    

Document Your Code

Why it’s important: Well-documented code is easier to maintain, especially when collaborating with other team members. It ensures that others (and even future you) can understand the purpose of each module, parameter, and resource.

How to implement it:

  • Add comments to describe complex logic, parameters, and resource configurations.
  • Include a readme file that explains how to use the Bicep templates and what each module does.

Example:


// This module deploys a virtual network with subnets
module vnet './network/vnet.bicep' = {
  name: 'vnetDeployment'
  params: {
    vnetName: 'myVNet'
    addressPrefix: '10.0.0.0/16'
  }
}

Conclusion

Writing clean, maintainable, and efficient Bicep code is critical for managing cloud infrastructure at scale.

By following these best practices—modularizing your code, using parameters and variables, adhering to naming conventions, using the latest API versions, leveraging loops and conditions, testing regularly, and documenting thoroughly—you’ll ensure that your Bicep templates remain adaptable, readable, and efficient.

By adopting these strategies, your infrastructure-as-code will be resilient to change, easier to debug, and more flexible for future requirements. Happy coding!