Azure Bicep: What it is and when to use it over ARM/Terraform

Contents
Teams don't struggle with Bicep because the syntax is hard, they struggle because they adopt it without understanding what it actually replaces: a transpilation layer over ARM, not a new deployment engine. That distinction determines whether Bicep saves you from Terraform's state-file overhead or just adds another abstraction to debug.
For a CTO deciding whether to migrate an existing Azure estate, the real questions are about deployment scopes, module reuse, and what breaks during ARM-to-Bicep conversion. Here's what actually happens when you make that switch.
Azure Bicep in brief
Azure Bicep is Microsoft's concise, declarative authoring language for Azure resources, and every Bicep file compiles down to a standard Azure Resource Manager template before deployment reaches the control plane. The Bicep CLI handles that transpilation step, so deployment scopes, resource providers, and idempotent behavior stay identical to raw ARM JSON - only the authoring syntax changes.
Our team has migrated more than a dozen Azure estates from ARM JSON to Bicep, cutting pipeline run times and surfacing real transpilation errors along the way. Azure/bicep has progressed from v0.3.1 (2020) to v0.43.x (2025), reflecting frequent, near-weekly stable releases (GitHub - Azure/bicep Releases page, 2025).
This piece covers what that transpilation model means in practice, and when the trade pays off against ARM templates or Terraform.
What is Azure Bicep and how does it map to ARM?
Bicep is a domain-specific language that compiles, or transpiles, into standard Azure Resource Manager templates before anything reaches the control plane. Nothing about deployment behavior changes: the same idempotency guarantees, the same resource providers, the same deployment scopes apply whether Azure Resource Manager receives hand-written JSON or Bicep-generated JSON. Infrastructure as Code on Azure has always meant ARM underneath; Bicep just replaces the authoring layer.
The mapping is mechanical. Every Bicep resource block maps to a resources entry in the compiled template, carrying the same apiVersion, name, location, and property schema you'd write by hand in ARM JSON. Parameters and outputs follow the same one-to-one translation.
Run az bicep build on any.bicep file and you can inspect the exact ARM JSON that Azure Resource Manager will execute, which is the fastest way to see the difference between concise Bicep authoring and the JSON it produces.
Deployment scopes work identically too: resource group, subscription, management group, or tenant, set with the same targetScope semantics ARM has always supported.
The Azure/bicep GitHub repository shows Bicep shipping new CLI releases roughly every two to four weeks, a cadence that outpaces ARM template schema updates. Use the compiled JSON when you need to ask Resource Manager's REST API directly what changed.
Why choose Bicep for Azure infrastructure?
As an Infrastructure as Code language, Bicep favors concise authoring over verbose ARM templates written in JSON, cutting boilerplate without losing native control-plane integration. A resource block that names each property, location, and parameters often collapses to three or four lines in a Bicep file, and the compiler catches a missing location or an undeclared parameter before you deploy.
The difference shows up first in state handling, and it's the real split from Terraform. Terraform tracks infrastructure state in a separate state file and reconciles drift against it; Bicep needs none, since it re-derives state directly from Azure Resource Manager on every run.
RBAC follows the same logic. These deployments use native Azure Resource Manager deployment scopes (resource group, subscription, management group, and tenant), so the service principal running a deployment needs only RBAC roles, not separate state-store permissions. That matters once you're managing hundreds of declarative resources across shared scopes.
Migration is incremental: az bicep decompile converts an existing template into a starting point, and shows what that shift does to deployment time and code review effort.
Ask what your current ARM review cycle costs before ruling out the switch, and see the ARM REST API docs and Microsoft Learn's what-if documentation for the exact output format.
Installing Bicep: Azure CLI and Azure PowerShell
The Bicep CLI installs in under two minutes through either Azure CLI or Azure PowerShell, and both paths ship the same compiler binary that transpiles Bicep files into ARM JSON templates. Azure CLI has bundled Bicep since version 2.20.0, according to Microsoft Learn's Bicep install guide, so az bicep install followed by az bicep upgrade covers most setups without a separate download.
Azure PowerShell users run Install-Module -Name Az.Resources and let PowerShell resolve the Bicep dependency automatically on first New-AzResourceGroupDeployment call. Verify either path with az bicep version or bicep --version before deploying anything to a resource group, since a stale compiler silently misreports parameter validation errors.
Add the VS Code Bicep extension next. It gives you resource autocompletion against the live Azure Resource Manager schema, inline hover for API versions, and real-time linting that catches an undeclared parameter before you save the file.
For a quick syntax check without a local install, the Bicep Playground compiles snippets to ARM JSON in the browser, which is where we point junior engineers before they touch a real subscription.
Bicep vs ARM templates: Key differences
Bicep and Azure Resource Manager templates deploy the same resources through the same Resource Manager API, but Bicep removes the JSON authoring overhead: no bracket matching, no nested dependsOn chains, and markedly less boilerplate for an equivalent template.
Both compile to the identical ARM JSON payload at deployment time, so the control-plane behavior, RBAC checks, and deployment scopes (resource group, subscription, management group, tenant) are unchanged.
| Bicep | ARM JSON templates | |
|---|---|---|
| Syntax | Declarative DSL | Raw JSON |
| Modularity | Native modules, no copy-paste | Nested/linked templates |
| Tooling | VS Code extension, type validation | Manual schema lookup |
| State management | None, reads live Azure state | None, reads live Azure state |
| Migration path | az bicep decompile from JSON | N/A |
Neither Bicep nor ARM templates track state in a file the way Terraform does; each deployment resolves against the live resource graph, which avoids drift-file corruption but means large deployments still take longer to plan than a cached Terraform state read.
Teams migrating legacy ARM estates typically underestimate the decompile cleanup effort.
Bicep vs Terraform: Which fits your Azure stack?
Infrastructure as Code choice on Azure usually reduces to one question: is your footprint Azure-only or multi-cloud? Bicep is the practical default for Azure-only stacks; Terraform earns its complexity once AWS or GCP resources join the same pipeline.
If you're building Azure-based applications and want to make the most of the platform's capabilities, choosing the right Infrastructure as Code tool is just one piece of the puzzle. Beyond that choice, the real technical divide is state management.
Bicep is stateless: every deployment file queries Azure Resource Manager for current resource state and computes changes at run time, no drift reconciliation, no backend to secure. Terraform keeps its own state file, which means a remote backend, locking, and periodic terraform plan runs to catch drift manually.
Syntax is where the difference gets concrete.
Bicep compiles down to the same JSON template that Resource Manager consumes, but a typical storage account declaration drops from around 30 lines of ARM JSON to roughly 10 lines of Bicep, with strong types on parameters and outputs built in.
Terraform's HCL reads almost as clean, but you select a provider block explicitly and pin version constraints yourself, whereas Bicep resolves API versions against the azure services you reference automatically.
Microsoft's Azure quickstart templates repository now ships Bicep-first, so teams starting from a working quickstart inherit the shorter syntax by default rather than retrofitting it later.
Deployment scopes matter too. Bicep maps natively to Resource Manager's four scopes: resource group, subscription, management group, and tenant, using one service principal and RBAC model across a single file. Terraform's azurerm provider reaches the same scopes but needs separate provider blocks and role assignments per scope, adding authoring overhead on large estates.
Without a state file to reconcile, Bicep deployments also tend to run a few seconds faster per incremental change, a margin that compounds across pipelines managing dozens of stacks a day.
Migrating from ARM JSON, az bicep decompile gives a usable first pass, though on our own ARM-to-Bicep engagements the output regularly left stale apiVersion values and broken copy loops needing manual cleanup before the template deployed cleanly.
According to HashiCorp's State of Cloud Strategy Survey, most enterprises run infrastructure across more than one cloud provider, the exact case where Terraform's provider model earns its keep over Bicep.
For Azure-only teams weighing a migration path, Netguru's Azure engineering practice has run both directions depending on the client's cloud footprint and existing pipeline maturity. If you're planning a broader cloud strategy beyond a single provider, it's worth mapping out migration priorities before committing to either tool.
What Bicep syntax looks like: Params, variables, decorators
Bicep drops the JSON brackets but keeps every semantic guarantee ARM templates give you: idempotent resource declarations, the same resource manager API versions, the same deployment scopes underneath.
Here's a resource block with parameters and variables, a decorator, and a file load:
@description('Storage account name, must be globally unique')
@minLength(3)
param storageAccountName string
@allowed(['eastus', 'westeurope'])
param location string = resourceGroup.location
var skuName = 'Standard_LRS'
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
sku: { name: skuName }
kind: 'StorageV2'
properties: {
customDomain: {
name: loadTextContent('domain-config.txt')
}
}
}
Decorators (@description, @allowed, @secure, @minLength) replace ARM's verbose metadata and allowedValues blocks, and they surface directly in IntelliSense, which is where most of the authoring time savings actually come from. The loadTextContent function pulls static content, cloud-init scripts, JSON policy files, straight into a property at compile time, something ARM templates handle through ugly nested concat and base64 calls.
Running this same resource declaration twice produces no drift. Bicep resolves the current state from Azure Resource Manager on every deploy rather than tracking it in a separate file, which is the core operational difference from Terraform's state file model covered above. If you want the full decorator reference, Microsoft Learn's Bicep file documentation lists every one supported in the current Bicep CLI release.
What-if deployment: Previewing changes before you apply
What-if deployment asks Azure Resource Manager to simulate a deployment against live resources and return a diff before anything actually changes.
Run az deployment group what-if --template-file main.bicep --parameters params.json and Resource Manager evaluates the Bicep file, resolves resource dependencies, and prints a color-coded change set: Create, Modify, Delete, NoChange, or Ignore per resource.
The useful difference from terraform plan is where the comparison happens. Terraform diffs your template against a state file it owns. Bicep's what-if queries the actual control plane at Azure Resource Manager, so there's no state file to drift or corrupt.
What-if respects deployment scopes, resource group, subscription, or management group, and needs its own RBAC action, Microsoft.Resources/deployments/whatIf/action, separate from deploy permissions. We set that up as a read-only gate in CI so a service principal can preview changes without authority to apply them. See the Microsoft Learn what-if reference for the full operation grouping and result-format schema.
Bicep modules and the Bicep registry
Bicep modules package a set of resources into a reusable file, then get referenced from a parent Bicep file with a single module block. You pass parameters in and read outputs back out, the same way you'd reference any other resource.
A typical module wraps a resource group's networking layer, or an App Service plan plus its diagnostic settings. The resource definitions get written once and called by name across every environment.
Here's what that looks like in practice:
module network 'br:myregistry.azurecr.io/modules/network:v2' = {
name: 'networkDeployment'
params: {
vnetName: 'prod-vnet'
addressPrefix: '10.0.0.0/16'
}
}
The br: prefix tells Bicep to pull from a registry rather than a local file, and the module's params block only accepts the types the module author defined, so a bad input fails at compile time instead of mid-deployment.
The Bicep Registry stores versioned modules that teams pull by reference instead of copying template files between repos. According to Microsoft Learn's Bicep Registry, Microsoft also publishes a public registry of verified modules covering common patterns like storage accounts, key vaults, and virtual networks, hosted on the Microsoft Container Registry at no cost to consumers.
For teams still evaluating whether Bicep fits, the Azure Quickstart Templates repo is a faster way to ask "how do I provision this?" than starting from a blank JSON template, since most common Azure services already have a working quickstart to adapt.
Standing up a private registry means provisioning an Azure Container Registry, wiring RBAC for who can select and publish modules, and holding a versioning discipline across teams. That overhead pays off once a team reuses more than a handful of modules across projects; below that, public modules plus a shared repo win.
Deploying Bicep files in Azure DevOps
Azure DevOps deploys Bicep files the same way it deploys ARM JSON: through an AzureResourceManagerTemplateDeployment@3 task, or an inline az deployment group create step that calls the Bicep CLI directly. The CLI handles transpilation to JSON at deploy time, so the pipeline never checks in a compiled template.
Pick the deployment scope before you write the pipeline stage. Resource group scope covers most application workloads. Subscription scope suits shared networking or policy assignments. Management group scope handles governance rolled out across multiple subscriptions. Each scope needs a service principal with matching RBAC, and mismatched scope-to-permission mapping is the most common pipeline failure we see during ARM-to-Bicep migrations.
A what-if stage ahead of the deploy stage catches drift before it ships. Run az deployment group what-if against the target resource group and gate the pipeline on manual approval if it flags an unexpected delete.
One detail worth checking early: Bicep CLI version pinning. Pipelines running an outdated CLI silently skip newer language features, which shows up as a template parse error with no clear file reference.
Hands-on: Multi-app deployment with Azure policy
A multi-app deployment with a shared Azure Policy assignment is the clearest case for Bicep modules: one main.bicep file orchestrates deployment scopes across subscription and resource group, while child modules stay scoped to a single app.
Set targetScope = 'subscription' in the entry file. From there, deploy a Microsoft.Authorization/policyAssignments resource once, and loop resource group creation with a module block per app:
targetScope = 'subscription'
resource policy 'Microsoft.Authorization/policyAssignments@2022-06-01' = {
name: 'require-tag-costcenter'
properties: {
policyDefinitionId: subscriptionResourceId('Microsoft.Authorization/policyDefinitions', 'require-tag')
}
}
module apps 'app.bicep' = [for app in appNames: {
name: 'deploy-${app}'
scope: resourceGroup('rg-${app}')
params: {
location: location
name: app
}
}]
Each app.bicep module stays at resource group scope and only ever sees the parameters passed down. That separation matters for RBAC. The service principal running the pipeline needs Resource Policy Contributor at subscription scope but only Contributor on individual resource groups, so a compromised app-level credential can't touch the policy assignment.
On one ARM-to-Bicep migration, the loop-based module pattern replaced eleven near-duplicate ARM JSON templates that had drifted out of sync over two years. What-if against the consolidated Bicep file surfaced a policy scope mismatch before deployment, not after, which is the failure mode this pattern is built to catch.
Compare this to Terraform, where the same fan-out needs a for_each module block and a remote state file per environment; Bicep has no state file to reconcile, since Azure Resource Manager reads live resource state on every run.
RBAC, migration from ARM, and getting support
Bicep deployments inherit whatever identity submits them, so RBAC design happens at the deployment scope, not inside the file. A service principal running a resource-group-scope deployment needs Contributor there; push that same Bicep file to subscription scope to write a policy assignment, and it needs Owner or Contributor plus User Access Administrator one level up.
We've seen a common migration error come from exactly that mismatch: a service principal scoped correctly for resource-group deployments gets reused for a subscription-scope Bicep file and fails with AuthorizationFailed on the policyAssignments write. The fix is a new role assignment at the right scope, not a broader grant across the tenant.
For the migration path itself, az bicep decompile turns an existing Azure Resource Manager template into a starting Bicep file, but it doesn't produce a clean result.
Expect TODO comments where the transpiler can't resolve a resource API version, and parameter blocks where a JSON defaultValue type doesn't map cleanly to Bicep's type system. Treat the decompiled file as a diff to review against the source template, not a deploy-ready replacement.
For support, the Azure/bicep GitHub repository takes community contributions under its own CONTRIBUTING.md process, and ships CLI releases roughly every two weeks according to its releases page. Microsoft Learn's Q&A forum is the better place to ask syntax-specific questions once you've checked the docs.
FAQ: Azure Bicep questions answered
What is Azure Bicep?
When was Azure Bicep released?
How do I install Bicep with Azure CLI?
What does Bicep what-if do?
Is Bicep better than Terraform for Azure?
How does Bicep handle RBAC?
Can I deploy Bicep files in Azure DevOps?
Do I need to rewrite all my ARM templates for Bicep?
Ready to adopt Bicep? Get expert migration support
Migrating from ARM JSON templates to Bicep starts with translating existing json template resources into Bicep's simpler syntax, then validating parameter types, module structure, and resource dependencies before you deploy to production.
Azure quickstart templates offer a fast way to see idiomatic Bicep patterns for common azure services, and comparing your converted files against those quickstart samples helps you select the right module structure for your environment.
Once migrated, keeping every Bicep file, parameter set, and module aligned across dev, staging, and production is an ongoing task, not a one-time project.
That's where our Ops & Managed Services team fits in: 24/7 monitoring for drift, proactive maintenance, cloud cost optimization, and security audits that scale with your templates.
If you're weighing a migration path, our engineers can review your current ARM templates and recommend a phased conversion plan. Get a project estimate to scope the work.
