Infrastructure as Code in Azure: ARM, Bicep, and Terraform
Infrastructure as Code (IaC) means defining your cloud resources in text files and applying them with tooling — instead of clicking through the Azure portal. Azure supports three main IaC approaches: ARM templates, Bicep, and Terraform. Each has a different syntax and different trade-offs, but all three give you the same core benefit: reproducible, version-controlled infrastructure.
Why Infrastructure as Code matters
When you create resources through the Azure portal, no one can tell you exactly what you clicked, in what order, or what settings you chose. Six months later, rebuilding that environment from memory is painful. With IaC, the configuration lives in a file you can read, review in a pull request, store in git, and re-apply whenever you need a new environment.
The practical benefits are tangible:
- Repeatability. You can spin up a staging environment that is identical to production by running one command.
- Auditability. Git history shows who changed what and why.
- Disaster recovery. If a resource group is accidentally deleted, you can recreate everything from source.
- Drift detection. Tools can compare what is deployed against what is in code and flag differences.
ARM templates: the original Azure IaC format
Azure Resource Manager (ARM) templates are JSON documents that describe the resources you want in a resource group. When you submit an ARM template, Azure’s Resource Manager API processes it and creates or updates resources to match the declared state.
Here is a minimal ARM template that creates a storage account:
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"storageAccountName": {
"type": "string",
"metadata": {
"description": "Globally unique storage account name"
}
},
"location": {
"type": "string",
"defaultValue": "[resourceGroup().location]"
}
},
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-01-01",
"name": "[parameters('storageAccountName')]",
"location": "[parameters('location')]",
"sku": {
"name": "Standard_LRS"
},
"kind": "StorageV2",
"properties": {
"accessTier": "Hot"
}
}
]
}
Deploying ARM templates with the Azure CLI
# Deploy an ARM template to a resource group
az deployment group create \
--resource-group my-rg \
--template-file storage.json \
--parameters storageAccountName=myuniquestorage123
# Deploy at the subscription level (for resource groups, policies, etc.)
az deployment sub create \
--location eastus \
--template-file subscription-level.json
# What-if: preview changes before applying
az deployment group what-if \
--resource-group my-rg \
--template-file storage.json \
--parameters storageAccountName=myuniquestorage123
ARM template deployment is idempotent by default. Running the same template twice will not create duplicate resources — it will update the existing resource to match the template. However, the behavior of properties not specified in the template depends on the deployment mode (complete vs. incremental).
Bicep: ARM templates without the JSON pain
Bicep is a language designed by Microsoft specifically for deploying Azure resources. It compiles to ARM JSON, so it uses the same underlying API, but the syntax is dramatically cleaner. You get type checking, IDE support in VS Code, and a module system for reusable components.
The same storage account in Bicep looks like this:
@description('Globally unique storage account name')
param storageAccountName string
@description('Azure region for the storage account')
param location string = resourceGroup().location
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
}
}
output storageAccountId string = storageAccount.id
# Deploy a Bicep file directly — az CLI compiles it automatically
az deployment group create \
--resource-group my-rg \
--template-file storage.bicep \
--parameters storageAccountName=myuniquestorage123
# Compile Bicep to ARM JSON (for inspection or use in pipelines that require JSON)
az bicep build --file storage.bicep
# Decompile existing ARM JSON to Bicep (quality varies)
az bicep decompile --file storage.json
Bicep modules for reusable infrastructure
Bicep modules let you break large deployments into smaller, reusable files. A module is just a Bicep file that another Bicep file references. This is the Bicep equivalent of Terraform modules.
// main.bicep — calls a storage module
module storage './modules/storage.bicep' = {
name: 'storageDeployment'
params: {
storageAccountName: 'myuniquestorage123'
location: 'eastus'
}
}
// Use the module output
output storageId string = storage.outputs.storageAccountId
Terraform: multi-cloud IaC for Azure
Terraform by HashiCorp is an open-source IaC tool that uses its own language called HCL (HashiCorp Configuration Language). The Azure provider for Terraform wraps the ARM API and lets you manage Azure resources using the same workflow you would use for AWS, GCP, or any other cloud.
Terraform maintains a state file that tracks what it has deployed. On each run, it compares the desired configuration (your HCL files) against the current state and makes only the necessary changes.
# main.tf — Azure storage account with Terraform
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.90"
}
}
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "example" {
name = "example-rg"
location = "East US"
}
resource "azurerm_storage_account" "example" {
name = "myuniquestorage123"
resource_group_name = azurerm_resource_group.example.name
location = azurerm_resource_group.example.location
account_tier = "Standard"
account_replication_type = "LRS"
account_kind = "StorageV2"
}
output "storage_account_id" {
value = azurerm_storage_account.example.id
}
# Initialize the working directory and download providers
terraform init
# Preview changes
terraform plan
# Apply changes
terraform apply
# Destroy all resources managed by this config
terraform destroy
Comparing ARM, Bicep, and Terraform
| Feature | ARM Templates | Bicep | Terraform |
|---|---|---|---|
| Language | JSON | Bicep DSL | HCL |
| Azure-native | Yes | Yes | No (via provider) |
| Multi-cloud | No | No | Yes |
| State management | None (ARM handles it) | None (ARM handles it) | Requires state backend |
| Module system | Linked templates | Bicep modules | Terraform modules |
| Learning curve | High | Medium | Medium |
| Best for | Legacy projects | Azure-only teams | Multi-cloud / large orgs |
Choosing the right tool for your team
If you are starting fresh on an Azure-only project, Bicep is the recommended choice. Microsoft actively develops it, it integrates natively with Azure services, and the learning curve is lower than ARM JSON. The VS Code Bicep extension gives you IntelliSense, linting, and visualization for free.
If your organization already uses Terraform across AWS and GCP, adding the Azure provider to your existing Terraform workflow is almost certainly the right call. The consistency of using one tool across clouds is worth more than the native advantages of Bicep.
ARM templates are worth knowing because many Azure quickstart examples still use them, and some third-party tools export ARM JSON. But for new work, Bicep or Terraform are better choices.
You do not have to choose just one. Many teams use Terraform to manage foundational infrastructure (resource groups, virtual networks, Key Vault) and Bicep or ARM templates for application-layer resources that change more frequently. Mixing tools at the right layer is a valid architecture.
Common mistakes
- Hardcoding secrets in templates. Never put passwords, connection strings, or API keys directly in ARM, Bicep, or Terraform files. Reference them from Azure Key Vault using secure parameter types or Terraform’s Key Vault data sources.
- Not using what-if or plan before applying. Running
az deployment group createorterraform applywithout previewing changes first is risky in production. Always run--what-iforterraform planand review the output before confirming. - Storing Terraform state locally. The default
terraform.tfstatefile on your laptop will be out of sync with anyone else on the team. Always configure remote state in Azure Blob Storage from day one, even for small projects.
Summary
- Infrastructure as Code replaces manual portal configuration with version-controlled, repeatable deployments.
- ARM templates are Azure-native JSON — verbose but foundational. Bicep is a cleaner DSL that compiles to ARM JSON and is the recommended Azure-native choice.
- Terraform uses HCL, manages state, and supports multi-cloud — ideal for organizations already invested in it or running resources across multiple providers.
- All three tools support preview modes (what-if / plan) to review changes before applying them.
- Never hardcode secrets in IaC files — use Key Vault references or Terraform’s sensitive variable handling.
Frequently asked questions
What is the difference between ARM templates and Bicep?
Bicep is a domain-specific language that compiles down to ARM JSON. Bicep is much easier to read and write, supports modules, and has better tooling. ARM templates are verbose JSON files. Both deploy the same underlying Azure resources.
Should I use Bicep or Terraform for Azure?
Use Bicep if your team is Azure-only and wants tight integration with Azure features like deployment stacks. Use Terraform if you manage multi-cloud infrastructure, already have Terraform expertise, or want a large ecosystem of providers and modules.
Is Infrastructure as Code only for large teams?
No. Even solo developers benefit from IaC. It makes environments reproducible, prevents configuration drift, and lets you version-control your infrastructure alongside your application code.