Azure Bicep vs ARM vs Terraform: Which IaC Tool Actually Wins for Azure?

Every team that lands on Azure eventually hits the same wall: someone opens a 400-line ARM JSON template, scrolls for thirty seconds without seeing the bottom, and asks "is there a better way?" There is. Several, actually — and that’s the problem.

Bicep, ARM, and Terraform all solve the same surface-level problem (describe infrastructure, deploy it repeatably), but they make wildly different tradeoffs. Pick the wrong one and you’re either locked into a Microsoft-only workflow or drowning in state file drama six months into a project.

This article cuts through the marketing noise. You’ll get honest syntax comparisons, the real gotchas nobody mentions in official docs, and a concrete recommendation for each scenario.


What We’re Actually Comparing

ARM templates — Azure Resource Manager’s native format. Raw JSON fed directly to the Azure API. It’s been around since 2014 and it’s what both Bicep and Terraform compile down to, ultimately.

Bicep — Microsoft’s purpose-built DSL that transpiles to ARM JSON. Launched in 2020, it hit stable 1.0 in 2022. Think of it as "ARM with a human-readable syntax layer."

Terraform — HashiCorp’s multi-provider IaC tool using HCL. The Azure provider (azurerm) is mature and actively maintained. Terraform manages its own state externally from Azure.

None of these is universally better. The right pick depends on your team, your multi-cloud footprint, and how much you care about state management.


ARM Templates: The Baseline Nobody Actually Enjoys

ARM JSON is verbose, deeply nested, and punishing to write by hand. But it’s worth understanding because everything else compiles to it — and when something breaks in a Bicep or Terraform deployment, you’re often reading ARM error messages anyway.

A minimal storage account in ARM looks like this:

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "storageAccountName": {
      "type": "string"
    },
    "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": {
        "supportsHttpsTrafficOnly": true,
        "minimumTlsVersion": "TLS1_2"
      }
    }
  ]
}

That’s 36 lines for one storage account. No modules, no loops, no conditions yet.

When ARM makes sense: Almost never for greenfield work. You’ll use it when you’re consuming someone else’s published template (Azure Quickstart Gallery, ISV deployment packages) or when you’re working inside Azure Portal’s "Deploy to Azure" button ecosystem and Bicep isn’t supported by the tooling yet.

ARM Gotchas

Complete vs Incremental deployment mode is a footgun that has deleted production resources. In Complete mode, Azure removes any resources in the resource group that aren’t in the template. The default for most tooling is Incremental, but if you or a script ever flips that mode, you’ll have a bad day. Always be explicit:

"mode": "Incremental"

apiVersion staleness. ARM templates pin API versions per resource. You can run a template with a 2018 API version in 2026 — it’ll work, but you’ll silently miss new properties and features. There’s no automatic upgrade path.


Bicep: ARM for People Who Don’t Hate Themselves

Bicep solves ARM’s readability problem without introducing external state or multi-cloud complexity. The same storage account:

// main.bicep
param storageAccountName string
param location string = resourceGroup().location

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
  properties: {
    supportsHttpsTrafficOnly: true
    minimumTlsVersion: 'TLS1_2'
  }
}

19 lines. Clean. Readable. No closing braces to count.

Bicep also brings real language features: modules, loops, conditions, string interpolation, and first-class VS Code support with IntelliSense that actually knows Azure resource schemas.

Modules

Bicep modules are the killer feature. You split infrastructure into reusable .bicep files and compose them:

// network.bicep — reusable VNet module
param vnetName string
param addressPrefix string = '10.0.0.0/16'
param location string = resourceGroup().location

resource vnet 'Microsoft.Network/virtualNetworks@2023-09-01' = {
  name: vnetName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [addressPrefix]
    }
  }
}

// Export for use in parent template
output vnetId string = vnet.id
output vnetName string = vnet.name
// main.bicep — compose modules
module network 'modules/network.bicep' = {
  name: 'networkDeployment'
  params: {
    vnetName: 'prod-vnet'
    addressPrefix: '10.10.0.0/16'
  }
}

// Reference output from the network module
module appService 'modules/appservice.bicep' = {
  name: 'appServiceDeployment'
  params: {
    vnetId: network.outputs.vnetId  // implicit dependency, correct order guaranteed
  }
}

The implicit dependency tracking is genuinely smart — Bicep figures out deployment order from output references, no dependsOn arrays needed in most cases.

Bicep Gotchas

No state file means no drift detection. Azure tracks what was deployed, but Bicep has no concept of "what does my template expect vs what’s actually there?" If someone clicks through the portal and adds a resource, Bicep has no way to alert you. This isn’t a bug — it’s a design choice — but teams used to Terraform’s plan output will miss it.

what-if is the closest you get to a plan:

az deployment group what-if \
  --resource-group myRG \
  --template-file main.bicep \
  --parameters main.bicepparam

It shows you what would change, but it’s based on a live API call against the actual resources — not a local state file. It’s good enough for most cases, but it has edge cases where it shows false positives.

Bicep Registry vs local modules — once your module library grows, you’ll want to store modules in an Azure Container Registry or the public Bicep Registry. The br: prefix syntax is non-obvious at first:

module network 'br/public:network/virtual-network:1.1.2' = {
  name: 'networkDeployment'
  params: { ... }
}

Start with local ./modules/ paths, migrate to a registry when you have more than 3-4 teams sharing modules.

Deployment history clutter. Every az deployment group create call adds an entry to Azure’s deployment history (capped at 800). In a busy CI/CD pipeline deploying dozens of times per day, you’ll hit that limit and deployments start silently failing. Use --name with a timestamp or build ID, and consider pruning old deployments:

az deployment group create \
  --name "deploy-$(date +%Y%m%d-%H%M%S)" \
  --resource-group myRG \
  --template-file main.bicep

Terraform: Multi-Cloud Power, Multi-Cloud Complexity

Terraform’s azurerm provider is mature — thousands of resources, well-maintained, and the community is huge. The same storage account:

# variables.tf
variable "storage_account_name" {
  type        = string
  description = "Name of the storage account"
}

variable "location" {
  type    = string
  default = "westeurope"
}

variable "resource_group_name" {
  type = string
}
# main.tf
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.100"
    }
  }

  # Store state in Azure Blob Storage — never use local state in production
  backend "azurerm" {
    resource_group_name  = "tfstate-rg"
    storage_account_name = "tfstateXXXXXX"   # must be globally unique
    container_name       = "tfstate"
    key                  = "prod/main.tfstate"
  }
}

provider "azurerm" {
  features {}
}

resource "azurerm_storage_account" "main" {
  name                     = var.storage_account_name
  resource_group_name      = var.resource_group_name
  location                 = var.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
  min_tls_version          = "TLS1_2"

  https_traffic_only_enabled = true
}

More verbose than Bicep for Azure-only resources, but the workflow is where Terraform shines:

terraform init   # download providers, configure backend
terraform plan   # show exactly what will change
terraform apply  # execute changes

The plan output is Terraform’s biggest advantage. It shows a precise diff before anything touches production:

Plan: 1 to add, 0 to change, 0 to destroy.

No surprises. This is non-negotiable for production workloads where a botched change can cascade.

Terraform Modules

HCL modules work similarly to Bicep modules but can be sourced from the Terraform Registry, Git repos, or local paths:

# Pull a community-maintained AKS module from the registry
module "aks" {
  source  = "Azure/aks/azurerm"
  version = "9.1.0"

  resource_group_name = azurerm_resource_group.main.name
  location            = var.location
  prefix              = "prod"

  network_plugin = "azure"
  vnet_subnet_id = module.network.vnet_subnets[0]
}

The Terraform Registry has high-quality Azure modules for AKS, App Service, VMs, and more. Bicep’s public registry is newer and thinner — this gap is closing, but Terraform is ahead today.

Terraform Gotchas

State file is a single point of failure. Lose it or corrupt it, and Terraform loses track of what it manages. Always use remote state with locking — Azure Blob Storage with lease-based locking is the standard:

backend "azurerm" {
  resource_group_name  = "tfstate-rg"
  storage_account_name = "tfstateXXXXXX"
  container_name       = "tfstate"
  key                  = "prod/main.tfstate"
  # Locking is automatic via blob lease — no extra config needed
}

Enable versioning on that blob container. Seriously. One bad terraform apply with a corrupted state and you need that history.

Provider version drift breaks pipelines. Pin your provider versions with ~> (pessimistic constraint) and commit the .terraform.lock.hcl file. If you don’t, a provider minor version bump can silently change behavior:

required_providers {
  azurerm = {
    source  = "hashicorp/azurerm"
    version = "~> 3.100"  # allows 3.100.x but not 4.x
  }
}

terraform destroy is too easy to type. Protect critical resources with prevent_destroy:

resource "azurerm_storage_account" "critical_data" {
  # ...
  lifecycle {
    prevent_destroy = true
  }
}

This causes terraform destroy to fail with an error rather than nuking your data. Use it on anything stateful — databases, storage accounts with real data, Key Vaults.

The azurerm provider resource naming doesn’t always match what Azure Portal shows. The portal calls it "Application Service Plan," Terraform calls it azurerm_service_plan. Budget 10 minutes of docs-hunting when you’re working with a resource for the first time.


Head-to-Head: The Real Decision Factors

ARM Bicep Terraform
Learning curve High Low-Medium Medium
Azure-only workloads Painful Great Good
Multi-cloud No No Yes
Drift detection No No (what-if helps) Yes (plan)
State management Azure-managed Azure-managed External file
Module ecosystem Limited Growing Mature
CI/CD integration Azure DevOps native Azure DevOps native Universal
Portal integration Full Full (via ARM transpile) Via azurerm provider

Production-Ready Patterns

Bicep in Azure DevOps

Bicep is a first-class citizen in Azure DevOps. The AzureResourceManagerTemplateDeployment task handles .bicep files natively since Az CLI 2.20+:

# azure-pipelines.yml
trigger:
  branches:
    include: [main]

stages:
  - stage: Deploy
    jobs:
      - deployment: DeployInfra
        environment: production
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureResourceManagerTemplateDeployment@3
                  inputs:
                    deploymentScope: 'Resource Group'
                    azureResourceManagerConnection: 'prod-service-connection'
                    subscriptionId: $(SUBSCRIPTION_ID)
                    resourceGroupName: 'prod-rg'
                    location: 'West Europe'
                    templateLocation: 'Linked artifact'
                    csmFile: 'infra/main.bicep'
                    csmParametersFile: 'infra/params/prod.bicepparam'
                    deploymentMode: 'Incremental'
                    deploymentName: 'deploy-$(Build.BuildId)'

Terraform in GitHub Actions

# .github/workflows/terraform.yml
name: Terraform

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  terraform:
    runs-on: ubuntu-latest
    permissions:
      id-token: write   # OIDC auth to Azure — no stored secrets
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "~1.8"

      - name: Terraform Init
        run: terraform init
        working-directory: infra/

      - name: Terraform Plan
        run: terraform plan -out=tfplan
        working-directory: infra/

      # Only apply on push to main, not on PRs
      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply tfplan
        working-directory: infra/

Use OIDC federation instead of storing client secrets. Both Azure DevOps and GitHub Actions support it — zero long-lived credentials in your CI system.


My Actual Recommendation

Azure-only shop, small-to-medium team: Use Bicep. The developer experience is excellent, it integrates deeply with Azure tooling, you get ARM’s reliability without ARM’s pain, and you don’t need to manage state files. The what-if command covers 90% of the "what will change?" use case.

Multi-cloud or significant non-Azure footprint: Terraform, no question. If you’re managing AWS resources alongside Azure, or you have GCP in the mix, a single IaC tool for everything beats context-switching between Bicep and provider-specific tools.

Existing Azure DevOps estate with heavy ARM usage: Migrate to Bicep incrementally. The az bicep decompile command converts ARM JSON to Bicep — it’s not perfect but it’s a reasonable starting point. ARM templates can stay in place where they’re working fine; there’s no urgency to rewrite them unless you’re actively maintaining them.

Never start a new project with raw ARM templates. The only valid reason to write ARM JSON in 2026 is if your toolchain absolutely cannot support Bicep yet, which is rare.

One thing both Bicep and Terraform teams should standardize on early: naming conventions, tagging strategy, and module boundaries. The tooling difference matters far less than having consistent naming across 50 resource groups. That’s the part that actually breaks teams at scale.


Resources

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646