ARM Templates vs Bicep
Compare ARM JSON templates and Bicep for Azure infrastructure as code deployments.
Prerequisites
- Azure subscription with resource deployment permissions
- Basic understanding of infrastructure as code concepts
ARM Templates vs Bicep: Infrastructure as Code on Azure
Azure Resource Manager (ARM) templates have been the native Infrastructure as Code (IaC) language for Azure since 2014. Bicep, introduced in 2020, is a domain-specific language (DSL) that compiles down to ARM JSON. It offers a dramatically cleaner syntax while maintaining full feature parity with ARM templates. Both are first-party Azure IaC tools that deploy through the same Azure Resource Manager engine, which means they have day-zero support for every Azure resource type.
This guide provides an in-depth comparison of ARM templates and Bicep, practical migration strategies, module design patterns, CI/CD integration, and guidance on when to choose Bicep, ARM, Terraform, or other IaC tools. Whether you are starting a greenfield Azure project or managing a legacy ARM template library, this guide helps you make informed decisions and adopt best practices.
Side-by-Side Syntax Comparison
The most immediate difference between ARM and Bicep is verbosity. Let's deploy the same resource, a Storage Account, in both languages to see the contrast.
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"storageAccountName": {
"type": "string",
"metadata": {
"description": "Name of the storage account"
}
},
"location": {
"type": "string",
"defaultValue": "[resourceGroup().location]"
},
"sku": {
"type": "string",
"defaultValue": "Standard_LRS",
"allowedValues": [
"Standard_LRS",
"Standard_GRS",
"Standard_ZRS"
]
}
},
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-01-01",
"name": "[parameters('storageAccountName')]",
"location": "[parameters('location')]",
"sku": {
"name": "[parameters('sku')]"
},
"kind": "StorageV2",
"properties": {
"minimumTlsVersion": "TLS1_2",
"supportsHttpsTrafficOnly": true,
"allowBlobPublicAccess": false
}
}
],
"outputs": {
"storageAccountId": {
"type": "string",
"value": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]"
},
"blobEndpoint": {
"type": "string",
"value": "[reference(parameters('storageAccountName')).primaryEndpoints.blob]"
}
}
}@description('Name of the storage account')
param storageAccountName string
param location string = resourceGroup().location
@allowed(['Standard_LRS', 'Standard_GRS', 'Standard_ZRS'])
param sku string = 'Standard_LRS'
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
sku: {
name: sku
}
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
supportsHttpsTrafficOnly: true
allowBlobPublicAccess: false
}
}
output storageAccountId string = storageAccount.id
output blobEndpoint string = storageAccount.properties.primaryEndpoints.blobThe Bicep version is roughly one-third the line count of the ARM equivalent. The reduction comes from eliminating JSON boilerplate (schema declarations, content versions), bracket function syntax ([parameters('name')] vs name), and explicit output type wiring. Bicep infers types, resolves references automatically, and uses a concise declarative syntax that is easier to read and write.
Key Differences
| Feature | ARM Templates | Bicep |
|---|---|---|
| Syntax | JSON (verbose, bracket functions) | Clean DSL (concise, type-safe) |
| File size | 3-5x larger for same resource | Compact, human-readable |
| Modularity | Linked/nested templates (complex URI handling) | Native modules with clean file references |
| IDE support | Basic JSON IntelliSense | Rich VS Code extension (validation, completions, snippets, refactoring) |
| Learning curve | Steep (JSON complexity, bracket functions) | Moderate (familiar to Terraform/HCL users) |
| Compilation | Direct: submitted as-is to ARM | Transpiles to ARM JSON before deployment |
| Resource support | Day-zero for all Azure resources | Day-zero (same resource provider APIs) |
| State management | None (Azure Resource Manager is the state) | None (Azure Resource Manager is the state) |
| Dependency management | Explicit dependsOn required | Automatic inference from resource references |
| Linting and validation | ARM-TTK tool (community) | Built-in linter with configurable rules |
Bicep Is the Future of Azure IaC
Microsoft has officially stated that Bicep is the recommended language for Azure native IaC. While ARM templates will continue to be supported indefinitely (Bicep compiles to ARM JSON), all new documentation, tooling investment, and feature development is focused on Bicep. If you are starting a new project, choose Bicep. If you have existing ARM templates, plan an incremental migration to Bicep as you update them.
Bicep Language Features
Bicep offers several language features that make infrastructure code more expressive and maintainable compared to raw ARM JSON.
Parameters with Decorators
Bicep uses decorators (similar to Python or TypeScript) to add metadata and constraints to parameters. This is much more readable than ARM's nested JSON properties.
@description('The Azure region for all resources')
param location string = resourceGroup().location
@description('Environment name used for resource naming')
@allowed(['dev', 'staging', 'production'])
param environment string
@description('The SKU for the App Service Plan')
@minLength(2)
@maxLength(10)
param appServiceSku string = environment == 'production' ? 'P2v3' : 'B1'
@description('Admin email for alerts')
@metadata({ example: 'admin@company.com' })
param adminEmail string
@secure()
@description('Database admin password')
param dbPassword string
// User-defined types (Bicep v0.12+)
type networkConfig = {
vnetName: string
subnetName: string
@description('CIDR prefix for the subnet')
addressPrefix: string
}
param networkSettings networkConfigConditional Deployments and Loops
param environment string
param regions string[] = ['eastus2', 'westeurope']
param deployWaf bool = environment == 'production'
// Conditional resource deployment
resource wafPolicy 'Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies@2023-05-01' = if (deployWaf) {
name: 'waf-policy'
location: regions[0]
properties: {
policySettings: {
mode: 'Prevention'
state: 'Enabled'
}
}
}
// Loop over regions to deploy resources
resource storageAccounts 'Microsoft.Storage/storageAccounts@2023-01-01' = [for (region, i) in regions: {
name: 'storage${environment}${i}'
location: region
sku: { name: 'Standard_ZRS' }
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
supportsHttpsTrafficOnly: true
}
}]
// Loop with filtering
var prodRegions = filter(regions, r => r != 'westeurope' || environment == 'production')
// Output array from loop
output storageAccountIds array = [for i in range(0, length(regions)): storageAccounts[i].id]Bicep Modules: Clean Reusability
One of Bicep's biggest advantages over ARM templates is its native module system. Modules let you encapsulate resource definitions into reusable, parameterized components. In ARM, modularity required linked templates with complex URI handling, SAS tokens for private storage, and nested deployment orchestration. In Bicep, you simply reference another .bicep file.
@description('Name for the App Service and plan')
param appName string
param location string = resourceGroup().location
@allowed(['B1', 'B2', 'P1v3', 'P2v3', 'P3v3'])
param sku string = 'B1'
param subnetId string = ''
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: '${appName}-plan'
location: location
sku: {
name: sku
}
kind: 'linux'
properties: {
reserved: true
}
}
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
name: appName
location: location
identity: {
type: 'SystemAssigned'
}
properties: {
serverFarmId: appServicePlan.id
httpsOnly: true
virtualNetworkSubnetId: !empty(subnetId) ? subnetId : null
siteConfig: {
minTlsVersion: '1.2'
ftpsState: 'Disabled'
alwaysOn: sku != 'B1'
linuxFxVersion: 'NODE|20-lts'
}
}
}
output appUrl string = 'https://${webApp.properties.defaultHostName}'
output appId string = webApp.id
output principalId string = webApp.identity.principalIdtargetScope = 'resourceGroup'
param environment string = 'production'
param location string = resourceGroup().location
// Deploy frontend using the reusable module
module frontendApp 'modules/appService.bicep' = {
name: 'frontend-deployment'
params: {
appName: 'myapp-frontend-${environment}'
location: location
sku: environment == 'production' ? 'P1v3' : 'B1'
}
}
// Deploy backend using the same module with different params
module backendApp 'modules/appService.bicep' = {
name: 'backend-deployment'
params: {
appName: 'myapp-backend-${environment}'
location: location
sku: environment == 'production' ? 'P2v3' : 'B1'
}
}
// Use outputs from modules as inputs to other resources
module keyVaultAccess 'modules/keyVaultRbac.bicep' = {
name: 'keyvault-access'
params: {
keyVaultName: 'myapp-${environment}-kv'
principalId: frontendApp.outputs.principalId
roleName: 'Key Vault Secrets User'
}
}
output frontendUrl string = frontendApp.outputs.appUrl
output backendUrl string = backendApp.outputs.appUrlBicep Registry for Shared Modules
For organizations with multiple teams, the Bicep module registry allows you to publish and consume modules from Azure Container Registry. This creates a centralized library of approved, versioned infrastructure modules that teams can reference.
# Create an Azure Container Registry for Bicep modules
az acr create --name myorgbicepregistry --resource-group infra-rg --sku Basic
# Publish a module to the registry
az bicep publish \
--file modules/appService.bicep \
--target br:myorgbicepregistry.azurecr.io/bicep/modules/app-service:v1.2.0
# In your main.bicep, reference the registry module:
# module frontend 'br:myorgbicepregistry.azurecr.io/bicep/modules/app-service:v1.2.0' = {
# name: 'frontend'
# params: { ... }
# }
# Configure bicepconfig.json for module aliases
# This simplifies registry references in Bicep files{
"moduleAliases": {
"br": {
"myorg": {
"registry": "myorgbicepregistry.azurecr.io",
"modulePath": "bicep/modules"
}
}
},
"analyzers": {
"core": {
"enabled": true,
"rules": {
"no-hardcoded-location": { "level": "error" },
"no-unused-params": { "level": "warning" },
"no-unused-vars": { "level": "warning" },
"prefer-interpolation": { "level": "warning" },
"secure-parameter-default": { "level": "error" },
"use-recent-api-versions": { "level": "warning" },
"use-resource-symbol-reference": { "level": "warning" }
}
}
}
}Module Versioning Strategy
Use semantic versioning (major.minor.patch) for registry modules. Bump the major version for breaking changes (removed parameters, changed resource types), minor for new features (added parameters with defaults), and patch for bug fixes. Pin module versions in production deployments and use version ranges only in development. This prevents unexpected changes from breaking production infrastructure.
Migrating from ARM to Bicep
Azure provides a built-in decompiler that converts ARM JSON to Bicep. While the output may need manual cleanup (especially for complex templates with nested deployments or advanced copy loops), it provides a solid starting point for migration.
Migration Strategy
- Inventory: Catalog all ARM templates in your repository. Prioritize actively maintained templates for migration.
- Decompile: Use
az bicep decompileto convert each template. Review and fix any warnings or errors. - Refactor: Clean up the generated Bicep code. Extract modules, improve naming, add descriptions, and remove redundant code.
- Validate: Run
az deployment group what-ifto compare the Bicep output against the current state. Ensure no unintended changes. - Deploy: Deploy the Bicep version to a non-production environment first. Verify resource configurations match expectations.
- Retire: Once validated, update CI/CD pipelines to use Bicep files and archive the old ARM templates.
# Decompile an existing ARM template to Bicep
az bicep decompile --file azuredeploy.json
# Creates azuredeploy.bicep - review and clean up the output
# Build Bicep to ARM JSON (to verify compilation succeeds)
az bicep build --file main.bicep
# Creates main.json - the ARM template Bicep generates
# What-if deployment (preview changes without applying them)
az deployment group what-if \
--resource-group myRG \
--template-file main.bicep \
--parameters environment=staging
# Deploy a Bicep file directly (CLI handles transpilation automatically)
az deployment group create \
--resource-group myRG \
--template-file main.bicep \
--parameters environment=production
# Deploy with a parameters file
az deployment group create \
--resource-group myRG \
--template-file main.bicep \
--parameters @parameters.production.json
# Subscription-level deployment (for resource groups, policies, etc.)
az deployment sub create \
--location eastus2 \
--template-file subscription.bicep \
--parameters @parameters.json
# Management group deployment (for governance across subscriptions)
az deployment mg create \
--location eastus2 \
--management-group-id "MyOrg" \
--template-file governance.bicepDecompile Limitations
The decompiler handles most ARM constructs, but complex nested templates, certain copy loop patterns, some bracket functions with dynamic property access, and templates using the reference() function with unusual parameters may need manual adjustment. Always review the generated Bicep code, run the linter, and perform a what-if deployment before deploying decompiled templates to production. Treat decompilation as a starting point, not a finished product.
Deployment Scopes
Both ARM and Bicep support four deployment scopes, each targeting a different level of the Azure resource hierarchy. Understanding scopes is essential for deploying governance resources, subscriptions, and cross-resource-group architectures.
| Scope | Bicep targetScope | CLI Command | What You Deploy |
|---|---|---|---|
| Resource Group | targetScope = 'resourceGroup' | az deployment group create | VMs, storage, databases, app services |
| Subscription | targetScope = 'subscription' | az deployment sub create | Resource groups, policies, role assignments |
| Management Group | targetScope = 'managementGroup' | az deployment mg create | Policies, blueprints, cross-subscription governance |
| Tenant | targetScope = 'tenant' | az deployment tenant create | Management groups, tenant-level policies |
targetScope = 'subscription'
param location string = 'eastus2'
param environment string
// Create resource groups
resource prodRG 'Microsoft.Resources/resourceGroups@2023-07-01' = {
name: 'myapp-${environment}-rg'
location: location
tags: {
environment: environment
'cost-center': 'CC-1234'
'managed-by': 'bicep'
}
}
// Deploy resources into the resource group using a module
module infrastructure 'modules/infrastructure.bicep' = {
name: 'infra-deployment'
scope: prodRG
params: {
location: location
environment: environment
}
}
// Assign a policy at subscription level
resource requireTagPolicy 'Microsoft.Authorization/policyAssignments@2022-06-01' = {
name: 'require-env-tag'
properties: {
policyDefinitionId: '/providers/Microsoft.Authorization/policyDefinitions/871b6d14-10aa-478d-b466-ef391786538f'
displayName: 'Require environment tag on resource groups'
enforcementMode: 'Default'
parameters: {
tagName: { value: 'environment' }
}
}
}CI/CD Integration
Bicep integrates seamlessly with all major CI/CD platforms. The Azure CLI handles Bicep transpilation automatically, so your pipeline simply runs az deploymentcommands against .bicep files.
GitHub Actions Workflow
name: Deploy Infrastructure
on:
push:
branches: [main]
paths: ['infra/**']
pull_request:
branches: [main]
paths: ['infra/**']
permissions:
id-token: write # Required for OIDC authentication
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint Bicep files
run: az bicep lint --file infra/main.bicep
- name: Build Bicep (verify compilation)
run: az bicep build --file infra/main.bicep
preview:
needs: validate
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Azure Login (OIDC - no secrets)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: What-If Preview
run: |
az deployment group what-if \
--resource-group myapp-production-rg \
--template-file infra/main.bicep \
--parameters @infra/parameters.production.json
deploy:
needs: preview
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
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: Deploy Infrastructure
run: |
az deployment group create \
--resource-group myapp-production-rg \
--template-file infra/main.bicep \
--parameters @infra/parameters.production.jsonUse OIDC Authentication in Pipelines
The GitHub Actions workflow above uses OpenID Connect (OIDC) federated credentials instead of storing client secrets. This is the most secure approach for CI/CD authentication to Azure. Configure workload identity federation in Azure AD to trust tokens from your GitHub repository. This eliminates the need to store and rotate Azure credentials in GitHub Secrets.
Bicep vs Terraform Comparison
Terraform is the other major IaC tool used for Azure deployments. While both are excellent tools, they have fundamentally different architectures and strengths.
| Dimension | Bicep | Terraform |
|---|---|---|
| Cloud support | Azure only | Multi-cloud (Azure, AWS, GCP, 1000+ providers) |
| State management | None (ARM is the state) | Required (state file in backend) |
| Resource support | Day-zero for all Azure resources | Depends on provider version (usually days-weeks lag) |
| Drift detection | What-if preview (pre-deployment) | Plan + drift detection (continuous) |
| Resource removal | Requires complete deployment mode | Automatically removes unmanaged resources |
| Learning curve | Azure-specific, simpler for Azure-only teams | Broader ecosystem, transferable skills |
| Module ecosystem | Growing (Bicep registry, Azure Verified Modules) | Mature (Terraform Registry, thousands of modules) |
| Cost | Free (open source) | Free (open source) / Paid (HCP Terraform for state, governance) |
Choosing Between Bicep and Terraform
Choose Bicep if you are Azure-only, want the tightest Azure integration, prefer no state file management, and need day-zero support for new Azure features. Choose Terraform if you manage infrastructure across multiple cloud providers, want mature drift detection, need the extensive Terraform provider ecosystem, or your team already has Terraform expertise. Many organizations use both: Bicep for core Azure infrastructure and Terraform for multi-cloud and SaaS provider integrations.
Best Practices and Project Structure
A well-organized Bicep project follows consistent patterns for file structure, naming, and module design.
Recommended Project Structure
infra/
main.bicep # Entry point for deployment
bicepconfig.json # Linter rules, registry aliases
parameters/
dev.json # Development environment parameters
staging.json # Staging environment parameters
production.json # Production environment parameters
modules/
networking/
vnet.bicep # VNet module
nsg.bicep # NSG module
privateEndpoint.bicep # Private endpoint module
compute/
appService.bicep # App Service module
functionApp.bicep # Function App module
aks.bicep # AKS cluster module
data/
sqlDatabase.bicep # Azure SQL module
cosmosDb.bicep # Cosmos DB module
storageAccount.bicep # Storage account module
security/
keyVault.bicep # Key Vault module
rbac.bicep # RBAC assignment module
monitoring/
logAnalytics.bicep # Log Analytics workspace
alerts.bicep # Alert rules
scripts/
deploy.sh # Deployment helper script
validate.sh # Validation scriptNaming and Coding Conventions
- Use camelCase for parameter and variable names:
storageAccountName,appServiceSku - Add @description to all parameters: This generates documentation and improves IntelliSense
- Use @secure() for sensitive parameters: Prevents values from appearing in deployment logs
- Prefer string interpolation over concat():
'prefix-${name}-suffix'is clearer - Use meaningful resource symbolic names:
resource prodStoragenotresource sa1 - Keep modules focused: Each module should deploy one logical resource or a tightly coupled set
Avoid Secrets in Parameter Files
Never store secrets (passwords, API keys, connection strings) in parameter files that are committed to source control. Instead, use Key Vault references in your parameter files, pass secrets through pipeline variables, or use the @secure()decorator and provide values at deployment time via --parameters overrides.
When to Use What
While Bicep is the clear winner for most Azure-native scenarios, different tools may be more appropriate depending on your context:
- New Azure-native projects: Use Bicep. It is the most productive and best-supported option for Azure-only infrastructure.
- Multi-cloud environments: Consider Terraform. If you manage AWS, GCP, and Azure resources, Terraform provides a single language across all providers.
- Existing ARM template libraries: Migrate incrementally to Bicep. Use the decompiler and convert templates as you make changes to them.
- CI/CD pipeline templates: Bicep works in all pipelines that support Azure CLI. GitHub Actions, Azure DevOps, GitLab CI, and Jenkins all work seamlessly.
- Template Specs: Bicep files can be compiled to ARM and published as Template Specs for portal-based deployments by non-engineer stakeholders.
- Azure Verified Modules (AVM): The AVM initiative provides production-ready Bicep modules for common Azure resources, tested and maintained by Microsoft. Use these as building blocks instead of writing everything from scratch.
Key Takeaways
- 1Bicep is a domain-specific language that compiles to ARM JSON templates.
- 2Bicep is dramatically more concise, typically 50-70% fewer lines than equivalent ARM JSON.
- 3Bicep provides first-class VS Code support with IntelliSense and validation.
- 4ARM templates remain the deployment engine; Bicep is an authoring improvement.
- 5Bicep modules enable reuse and composition of infrastructure patterns.
- 6Existing ARM templates can be decompiled to Bicep for gradual migration.
Frequently Asked Questions
Is Bicep replacing ARM templates?
Can I convert existing ARM templates to Bicep?
How does Bicep compare to Terraform for Azure?
Does Bicep support all ARM template features?
What is a Bicep module?
Written by CloudToolStack Team
Cloud engineers and architects with hands-on experience across AWS, Azure, and GCP. We write guides based on real-world production patterns, not just documentation rewrites.
Disclaimer: This guide is for educational purposes. Cloud services change frequently; always refer to official documentation for the latest information. AWS, Azure, and GCP are trademarks of their respective owners.