Skip to content
Stribog

Delivery

All writing

Bicep vs Terraform: Better ARM Syntax, Same Azure-Only Scope

Bicep compiles to ARM JSON and keeps no state; Terraform keeps state you own. Neither makes Azure resources portable. What each leaves you at exit.

Stribog13 min read

Most comparisons stop at one line: Bicep for Azure-only teams, Terraform for multi-cloud ones. That line is true and nearly useless. A team that runs only on Azure still has to decide what its source of truth is, how it previews a change, how it deletes what it no longer declares, and what it would take to leave. Those are the questions an auditor and a platform lead ask, and the two tools answer them very differently.

This article works through each, using Microsoft's and HashiCorp's own documented limits rather than anecdote: what Bicep is underneath, what "no state file" turns out to mean, where each preview goes blind, the failure modes worth knowing before they page someone, and the exit ramps — with what each one carries and what none of them do.

The short answer: same engine, different record

Both tools end at Azure Resource Manager. A Bicep file is compiled to an ARM template and submitted as a deployment; Terraform's azurerm and azapi providers make ARM REST calls. The resources that come out are the same resources, governed by the same control plane, the same RBAC, and the same resource-provider API versions. Choosing Terraform does not make a Cosmos DB account or an AKS cluster any less Azure.

What does differ is the record each tool leaves and the workflow around it. Terraform keeps a state file — your inventory of what it manages — in a backend you choose, and every change is a plan diffed against that state. Bicep has no such file; Azure keeps the history of deployments and, if you use them, deployment stacks that track what a template manages. Terraform's workflow, state handling and review tooling also apply unchanged to the next provider you add. That transferability is the honest portability argument for Terraform. Resource portability is not.

Microsoft is unusually candid about this. The Bicep FAQ says that if you are happy using Terraform, there's no reason to switch, and that Microsoft is committed to making Terraform on Azure the best it can be. Neither vendor is pushing you to migrate; the decision is yours to make on its merits. If the same question is on your AWS estate, Terraform vs CloudFormation works it through for the provider-native tool there.

The resources are identical either way. The difference is which record you keep, and where it lives.

What Bicep actually is: ARM JSON with better ergonomics

Bicep is a language, not a deployment engine. Microsoft's FAQ is explicit: Bicep files compile to JSON, and that JSON is sent to Azure for deployment. The `bicep build` command converts a Bicep file to an ARM template, and the overview calls Bicep a transparent abstraction over ARM JSON: resource types, API versions and properties that are valid in an ARM template are valid in a Bicep file.

That design buys real advantages. Bicep supports new resource types and API versions, preview and GA, as soon as a resource provider introduces them — there is no provider release to wait for. The syntax is far kinder than ARM JSON: typed parameters, modules, symbolic references instead of resourceId() strings. A minimal storage account looks like this, and compiles cleanly with bicep build (tested against Bicep v0.48.1):

bicep
param location string = resourceGroup().location
param storageName string

resource stg 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: storageName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
  properties: {
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
  }
}

output storageId string = stg.id
storage.bicep. The API version is illustrative, not the newest; note the type@apiVersion string — it reappears unchanged at the exit.

The same design fixes Bicep's scope. Microsoft's FAQ says it is not currently planning for Bicep to extend beyond Azure, and its own Terraform-and-Bicep comparison describes Bicep as Azure-specific and not designed to work with other cloud services. Bicep extensions let a file reference resources beyond ARM; the documented examples are a Kubernetes extension, still in preview, and the Microsoft Graph extension, which manages a limited set of Graph resources — currently Microsoft Entra ID resources. Note that the deployment stacks known-issues page lists the Microsoft Graph provider as unsupported, so those Entra ID resources sit outside stack management even when the same file declares them. Useful reach into identity and clusters; nowhere near a second cloud.

'No state file' means Azure holds the record

The Bicep overview lists "no state or state files to manage: Azure stores all states" as a benefit. It is one — no backend to provision, no lock to break, no state surgery. But the record has not disappeared. It has moved into Azure, and Azure's copy has documented limits that a Terraform user never meets.

Per the deployment-history documentation, each scope — resource group, subscription, management group or tenant — stores up to 800 deployments. Past 700, Resource Manager deletes the oldest until the history is down to 600; what-if and validate operations trigger those deletions too. Giving a deployment the same name as one already in the history resets its place, moving it to the most recent position rather than adding a row. Two things stop the grooming: a CanNotDelete lock on the resource group or subscription, and a deploying identity without the Microsoft.Resources/deployments/delete action. Either way, a scope that reaches 800 fails its next deployment with an error. The second is easy to miss in a least-privilege pipeline. Check the count:

bash
# How close is each scope to the 800-deployment history cap?
az deployment group list --resource-group "<RESOURCE_GROUP>" --query "length(@)"
# Same check at subscription scope:
az deployment sub list --query "length(@)"

Terraform's record is a file you hold, which is both the point and the burden. HashiCorp's sensitive-data guide is plain that state and plan files can contain sensitive values, and that local state is a plaintext file that includes secrets defined in configuration. The ephemeral argument on variables and child-module outputs keeps Terraform from storing those values in state or plan files, but it exists only in Terraform 1.10 and later; pin required_version accordingly before relying on it. sensitive is a different and weaker control: on its own it redacts the value from CLI output and the HCP Terraform UI, and a sensitive value that is not also ephemeral is still written to state and plan files. The `azurerm` backend supports state locking and consistency checking with Azure Blob Storage's native capabilities, so the storage account holding state becomes one of the most sensitive assets in the subscription.

Bicep's equivalent control is the @secure() decorator: the parameter documentation says a secure parameter's value isn't saved to the deployment history and isn't logged. For an auditor the contrast is concrete. With Terraform you can show the state file, its encryption, its access policy and its lock — evidence you control. With Bicep the evidence is Azure's history, retained on Azure's terms and groomed by Azure's rules.

Preview and delete: what-if versus plan, stacks versus destroy

Terraform's plan is computed against its own state and the provider's reads. ARM's preview is what-if, and Microsoft documents its blind spots carefully. What-if can't resolve the reference() function, so every property set from an expression that includes it is reported as changing. Nondeterministic functions such as newGuid() and utcNow(), references to secure parameter values, and references to resources not deployed in the same template aren't evaluated. The output itself warns that results may contain false-positive predictions (noise).

Two limits matter more than noise, because they hide changes rather than invent them. What-if expands nested templates only until it reaches 500 nested templates, 800 resource groups in a cross resource-group deployment, or 5 minutes of expansion; after that, the remaining resources are marked Ignore. Separately, templateLink is not evaluated or expanded at all, so resources deployed through template links in nested deployments — including template spec references — simply do not appear in the result. Not marked; absent.

Incremental mode, the default, is the other trap. It leaves resources the template doesn't mention alone, but it is not a partial update of the resources it does mention: Microsoft calls it a common misunderstanding to think unspecified properties are left unchanged, because omitted properties are reset to their defaults. The same page warns that where a property can live on a resource or on a child resource, incremental updates need care. Its two examples: declare subnets through the subnets property of Microsoft.Network/virtualNetworks, not as Microsoft.Network/virtualNetworks/subnets children — defined on the virtual network, a redeploy does not lose them. And web-app site configuration lives on the child type Microsoft.Web/sites/config, which is not updated if you redeploy the app with an empty site-config object.

Stacks have their own preview. Stack what-if stores a named result, and a scope can hold up to 800 of them — a cap separate from the deployment-history cap and from the 800-stacks-per-scope cap, so running it does not consume deployment history. The same reference(), nondeterministic and secure-value differences can still appear, and expansion limits can still leave resources out. Noise reduction applies only to stacks deployed or updated since 13 August 2026, and Microsoft notes it does not remove every difference. One genuine documentation conflict: the conceptual guide says the retention interval must be between PT1H and PT3H; the CLI reference says 1 to 30 days and its examples pass --ri P5D. Trust what your installed CLI accepts.

bash
# main.bicep sets targetScope = 'subscription' and declares the resource group
# plus a module for the storage account. Microsoft's known-issues guidance:
# deleting a resource group bypasses a resource-group-scoped stack's deny
# assignments, so deploy at subscription scope.

# 1. Preview. Retention: the conceptual guide says PT1H-PT3H; the CLI reference
#    says 1-30 days (its examples use P5D). Use whatever
#    'az stack-whatif sub create --help' accepts on your installed CLI.
az stack-whatif sub create \
  --name "<WHATIF_RESULT_NAME>" \
  --location "<LOCATION>" \
  --stack-id "/subscriptions/<SUBSCRIPTION_ID>/providers/Microsoft.Resources/deploymentStacks/<STACK_NAME>" \
  --template-file main.bicep \
  --action-on-unmanage deleteResources \
  --deny-settings-mode denyDelete \
  --retention-interval "<RETENTION>"

# 2. Deploy. deleteResources deletes managed resources that leave the template
#    and detaches resource groups instead of deleting them.
az stack sub create \
  --name "<STACK_NAME>" \
  --location "<LOCATION>" \
  --template-file main.bicep \
  --action-on-unmanage deleteResources \
  --deny-settings-mode denyDelete
Preview, then create a subscription-scoped deployment stack.

Failure modes on call: where each one bites

Everything below comes from vendor documentation. The stacks known-issues page is a living document — at the time of writing it still says stack what-if is unsupported, which the dedicated what-if guide and the GA az stack-whatif CLI contradict — so read these as the state on the day this was written, not as permanent behaviour.

Bicep and ARM

  • History cap. A scope at 800 deployments fails the next deployment. Locks and least-privilege deployers both stop the automatic grooming that would have prevented it.
  • Resource-group delete. Deleting a resource group bypasses a resource-group-scoped stack's deny assignments. Microsoft's guidance is to deploy at subscription scope and declare the resource group in the Bicep file.
  • Resource move. Moving a managed resource out of its resource group removes its stack protection. The documented interim mitigation is a read-only lock on the resource group to block moves. Reads and the stack's deny-delete protection keep working while the lock is present, but a stack update does not proceed until the lock is removed: take it off for the duration of the update and reapply it afterward, in the same pipeline job so it is never left off by accident.
  • Key Vault secrets. Stacks can't delete Key Vault secrets; when removing them from a template, run the stack update or delete with detach mode.
  • Scope of management. Up to 800 stacks per scope, and a stack does not manage implicitly created resources.
  • Preview gaps. Ignore past the expansion limits, and template-linked resources missing from the result entirely.

Terraform

  • State contention. Every run against one state takes the backend lock, so parallel pipelines serialise on it, and a lock left behind by an interrupted run has to be released deliberately.
  • Secrets in state. Anything not marked ephemeral can land in state. The storage account and its access policies are part of your security boundary.
  • Provider lag. A new Azure feature reaches azurerm only when the provider ships it. The AzAPI provider is the mitigation: a thin layer over the ARM REST APIs that manages any resource type at any API version, with aztfmigrate to move resources between AzAPI and AzureRM in both HCL and state once the provider catches up.

For how state you own compares with configuration that converges, see Terraform vs Ansible; for splitting state into layers before contention becomes the bottleneck, see Terragrunt vs Terraform.

The exit ramps, and what each actually carries

Optionality is only real if the exit has been walked. From a Bicep estate there are four routes, and they carry very different things.

az bicep decompile is not an exit. It converts ARM JSON to Bicep, and Microsoft's decompile guide says there's no guaranteed mapping, so warnings and errors may need fixing. It moves you between two dialects of the same engine.

Hand translation to AzAPI is the most faithful route for a small estate, because a Bicep resource declaration already names the exact ARM type and API version the azapi_resource needs. Microsoft's Terraform VS Code extension can also paste ARM JSON as azapi_resource blocks. The storage account from earlier, against AzAPI v2.13.0:

hcl
terraform {
  required_providers {
    azapi = {
      source  = "Azure/azapi"
      version = "~> 2.13"
    }
  }
}

provider "azapi" {}

variable "resource_group_id" {
  type = string
}

variable "location" {
  type = string
}

variable "storage_name" {
  type = string
}

resource "azapi_resource" "stg" {
  type      = "Microsoft.Storage/storageAccounts@2023-05-01"
  name      = var.storage_name
  parent_id = var.resource_group_id
  location  = var.location

  body = {
    sku = {
      name = "Standard_LRS"
    }
    kind = "StorageV2"
    properties = {
      minimumTlsVersion     = "TLS1_2"
      allowBlobPublicAccess = false
    }
  }
}
The same storage account as an azapi_resource. The type@apiVersion string is copied verbatim from the Bicep declaration.

[Azure Export for Terraform](https://learn.microsoft.com/en-us/azure/developer/terraform/azure-export-for-terraform/export-terraform-overview) (aztfexport) is the bulk route: it exports a resource, a resource group or the resources matched by an Azure Resource Graph query predicate to Terraform HCL and state. It defaults to azurerm; --provider-name=azapi targets AzAPI instead. It collects telemetry by default; turn that off first. Its concepts page — last updated in May 2023, so treat it as documented behaviour, not re-tested against the current v0.20.0 — lists the review burden. For either provider, you must identify resources the configuration needs that sit outside the export scope (a role assignment is the example), define write-only properties such as passwords yourself before the code can create new resources, and refactor the hard-coded strings it generates into variables. For azurerm exports only, it documents cross-property constraints, where the provider can set two properties that conflict in generated configuration.

The v0.20.0 README states the success criterion: state and configuration consistent with the resources' remote state, i.e. terraform plan shows no diff. That is the rehearsal's hard gate; know which output mode produced the HCL. In v0.20.0 --config-mode defaults to minimal, which trims zero values, schema defaults and Optional+Computed attributes for readability. lossless keeps the Optional+Computed attributes but still drops schema defaults and, for SDKv2 providers, zero values that can be removed without changing semantics — so a plan can be clean without the HCL listing every live attribute. full keeps every property, including zero values and schema defaults, and the CLI help warns it may need manual edits to be valid. A clean plan proves consistency, not completeness.

bash
# Rehearse the exit for one resource group, without touching it.
aztfexport config set telemetry_enabled false

aztfexport resource-group \
  --provider-name=azapi \
  --non-interactive \
  --output-dir ./exit-rehearsal \
  "<RESOURCE_GROUP>"

cd ./exit-rehearsal
terraform init

# Gate (aztfexport v0.20.0): terraform plan must show no diff.
# A non-empty plan means STOP and reconcile before any apply.
# A clean plan is still not proof this HCL can rebuild the estate.
terraform plan
An exit rehearsal: export one resource group to AzAPI HCL plus state, then gate on the plan.
Every route ends on ARM. What changes is the record and the workflow you hold, not where the resources live.

Licence is not lock-in

The two questions get conflated, so separate them. On licence, Bicep is MIT-licensed. Terraform 1.6.0 and later is under the Business Source License, with IBM now named as licensor; the licence is per version, and each version converts to MPL 2.0 four years after it is published; the current release is v1.17.1. OpenTofu is the MPL-2.0 fork, and the trade-offs of moving to it are covered in the OpenTofu migration article.

On lock-in, none of those licences changes where the resources live. An MIT compiler targeting a single cloud's control plane is still single-cloud; a source-available tool managing Azure resources is still managing Azure resources. Licence governs your rights over the tool. Lock-in is about the resources, the record, and the cost of moving either.

Choosing for a decade, not a sprint

A decision rule that should survive a few reorganisations:

  • Bicep plus deployment stacks if Azure is the only estate you expect to run, and you are content for ARM to be the record. Deploy stacks at subscription scope, watch the history cap, and keep deny settings on.
  • Terraform or OpenTofu with AzAPI if you want one workflow across providers and a record you hold. Treat the state backend as a crown-jewel asset, and use AzAPI where azurerm lags.
  • Either way, rehearse the exit. Run the export against a non-production resource group once a year and record whether the plan came back clean. An exit you have never run is a belief, not an option.

The question is less which syntax is nicer than which record your successors will inherit. For where infrastructure-as-code stops and reconciliation begins, see Terraform vs Kubernetes.

§FAQ/Common questions

Frequently asked

Is Bicep better than Terraform for Azure?

For an Azure-only estate, Bicep supports new resource types and API versions as soon as they ship and needs no state backend. Terraform offers one plan-and-state workflow across providers and a record you hold; its AzAPI provider can manage any Azure resource type at any API version. Microsoft's own FAQ says teams happy with Terraform have no reason to switch.

Does Bicep have state like Terraform?

No state file, but Azure keeps the record: deployment history (up to 800 deployments per scope, groomed past 700) and, if you use them, deployment stacks that track the resources a template manages.

Is ARM complete mode deprecated?

Microsoft says complete mode will be gradually deprecated, without giving a date, and directs Bicep users to deployment stacks with actionOnUnmanage for deleting resources dropped from a template.

Can I convert Bicep to Terraform?

Not directly. Export the deployed resources with aztfexport (azurerm or, with --provider-name=azapi, AzAPI) to get HCL and state, then gate on a terraform plan with no diff. For small estates, hand-translate each resource to azapi_resource using the same type@apiVersion string.

bicep vs terraformterraform vs bicepazure deployment stacks actionOnUnmanagebicep what-if limitationsazapi provider vs azurermaztfexport bicep to terraform migration

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.