Skip to main content

Azure Bicep — Infrastructure as Code That's Actually Readable

· 8 min read
Goel Academy
DevOps & Cloud Learning Hub

ARM templates are powerful but painful. A simple storage account takes 30 lines of JSON with cryptic syntax, deeply nested properties, and string concatenation that makes your eyes bleed. Bicep is Microsoft's answer — a domain-specific language that compiles to ARM JSON but reads like actual code. Same deployment engine, same capabilities, a fraction of the complexity.

Bicep vs ARM Templates

Bicep is a transparent abstraction over ARM templates. When you deploy a Bicep file, the CLI compiles it to an ARM JSON template behind the scenes. The deployment itself is identical — same Azure Resource Manager, same API calls, same rollback behavior.

FeatureARM JSONBicep
SyntaxVerbose JSONClean declarative DSL
Line count3-5x moreConcise
Type safetyNone (string-based)Full IntelliSense and type checking
ModulesLinked/nested templates (URLs, complex)Simple module keyword
String interpolationconcat() function'Hello ${name}'
CommentsNot supported in JSON// and /* */
Learning curveSteepGentle
ToolingLimitedVS Code extension with autocomplete
# Install Bicep (comes with Azure CLI 2.20+)
az bicep install

# Check version
az bicep version

# Compile Bicep to ARM JSON (for inspection)
az bicep build --file main.bicep

# Decompile ARM JSON to Bicep
az bicep decompile --file azuredeploy.json

Bicep Syntax Basics

A Bicep file declares resources, parameters, variables, and outputs. Here is a storage account in both formats to see the difference:

ARM Template (32 lines):

{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"location": {
"type": "string",
"defaultValue": "[resourceGroup().location]"
}
},
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-01-01",
"name": "stprod2025",
"location": "[parameters('location')]",
"sku": { "name": "Standard_LRS" },
"kind": "StorageV2",
"properties": {
"supportsHttpsTrafficOnly": true,
"minimumTlsVersion": "TLS1_2"
}
}
]
}

Bicep (12 lines):

param location string = resourceGroup().location

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

Same result. Half the lines. No schema boilerplate. No concat(). No bracket hell.

Parameters and Variables

Parameters accept input values at deployment time. Variables compute values internally.

// Parameters with types, defaults, and constraints
@description('Environment name')
@allowed(['dev', 'staging', 'prod'])
param environment string

@description('Azure region for all resources')
param location string = resourceGroup().location

@minValue(1)
@maxValue(10)
param instanceCount int = 2

@secure()
param sqlAdminPassword string

// Variables for computed values
var prefix = 'goel-${environment}'
var storageName = '${replace(prefix, '-', '')}st'
var tags = {
Environment: environment
ManagedBy: 'Bicep'
DeployedAt: utcNow()
}

The @secure() decorator ensures the parameter value is never logged or displayed in deployment history. Always use it for passwords, connection strings, and API keys.

Outputs

Outputs expose values from your deployment for use by other templates, scripts, or CI/CD pipelines.

output storageAccountId string = storageAccount.id
output storageEndpoint string = storageAccount.properties.primaryEndpoints.blob
output webAppHostname string = webApp.properties.defaultHostName
# Read outputs after deployment
az deployment group show \
--resource-group rg-prod \
--name main-deployment \
--query "properties.outputs" \
--output json

Modules

Modules are Bicep's answer to code reuse. A module is just another Bicep file that you reference from your main file. Each module gets its own deployment scope.

// modules/storage.bicep
param name string
param location string
param sku string = 'Standard_LRS'

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

output id string = storageAccount.id
output name string = storageAccount.name
// main.bicep — consuming the module
param environment string
param location string = resourceGroup().location

module storage 'modules/storage.bicep' = {
name: 'storage-deployment'
params: {
name: 'st${environment}app2025'
location: location
sku: environment == 'prod' ? 'Standard_GRS' : 'Standard_LRS'
}
}

// Reference module outputs
output storageId string = storage.outputs.id

Modules can live in the local file system, a Bicep registry (ACR), or a template spec. For team-wide reuse, publish modules to your Azure Container Registry.

Conditional Deployments

Deploy resources only when conditions are met using the if keyword:

param environment string
param enableDiagnostics bool = true

// Only create Application Insights in production
resource appInsights 'Microsoft.Insights/components@2020-02-02' = if (environment == 'prod') {
name: 'appi-${environment}'
location: location
kind: 'web'
properties: {
Application_Type: 'web'
RetentionInDays: 90
}
}

// Diagnostics setting only if enabled
resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagnostics) {
name: 'diag-webapp'
scope: webApp
properties: {
workspaceId: logAnalytics.id
logs: [
{ category: 'AppServiceHTTPLogs', enabled: true }
{ category: 'AppServiceConsoleLogs', enabled: true }
]
}
}

Loops

Create multiple instances of a resource using for loops:

param regions array = ['eastus', 'westus2', 'northeurope']

// Create a storage account in each region
resource storageAccounts 'Microsoft.Storage/storageAccounts@2023-01-01' = [for (region, i) in regions: {
name: 'stmulti${i}${uniqueString(resourceGroup().id)}'
location: region
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {
supportsHttpsTrafficOnly: true
}
}]

// Loop with object array
param subnets array = [
{ name: 'snet-app', prefix: '10.0.1.0/24' }
{ name: 'snet-data', prefix: '10.0.2.0/24' }
{ name: 'snet-mgmt', prefix: '10.0.3.0/24' }
]

resource vnet 'Microsoft.Network/virtualNetworks@2023-05-01' = {
name: 'vnet-prod'
location: location
properties: {
addressSpace: { addressPrefixes: ['10.0.0.0/16'] }
subnets: [for subnet in subnets: {
name: subnet.name
properties: {
addressPrefix: subnet.prefix
}
}]
}
}

The existing Keyword

Reference resources that already exist without managing them:

// Reference an existing Key Vault to read secrets
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
name: 'kv-prod-2025'
scope: resourceGroup('rg-security')
}

// Use the existing Key Vault's URI
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
name: 'webapp-prod'
location: location
properties: {
siteConfig: {
appSettings: [
{
name: 'KEY_VAULT_URI'
value: keyVault.properties.vaultUri
}
]
}
}
}

Deployment Commands

# Deploy to a resource group
az deployment group create \
--resource-group rg-prod \
--template-file main.bicep \
--parameters environment=prod sqlAdminPassword=<secure-value>

# Deploy with a parameters file
az deployment group create \
--resource-group rg-prod \
--template-file main.bicep \
--parameters @params.prod.json

# What-if (preview changes before deploying)
az deployment group what-if \
--resource-group rg-prod \
--template-file main.bicep \
--parameters environment=prod

# Deploy at subscription scope (for resource groups, policies)
az deployment sub create \
--location eastus \
--template-file subscription.bicep

The what-if command is essential for production. It shows you exactly what will be created, modified, or deleted — without actually doing it. Always run what-if before deploying.

Complete Example — Web App + SQL + Key Vault

Here is a real deployment that creates an App Service, Azure SQL Database, and Key Vault with the SQL connection string stored as a secret:

// main.bicep — Complete deployment
@description('Environment name')
@allowed(['dev', 'staging', 'prod'])
param environment string

param location string = resourceGroup().location

@secure()
param sqlAdminPassword string

var prefix = 'goel-${environment}'
var tags = {
Environment: environment
Project: 'GoelAcademy'
ManagedBy: 'Bicep'
}

// App Service Plan
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
name: '${prefix}-asp'
location: location
tags: tags
sku: {
name: environment == 'prod' ? 'P1v3' : 'B1'
}
}

// SQL Server
resource sqlServer 'Microsoft.Sql/servers@2023-05-01-preview' = {
name: '${prefix}-sql'
location: location
tags: tags
properties: {
administratorLogin: 'sqladmin'
administratorLoginPassword: sqlAdminPassword
minimalTlsVersion: '1.2'
}
}

// SQL Database
resource sqlDb 'Microsoft.Sql/servers/databases@2023-05-01-preview' = {
parent: sqlServer
name: '${prefix}-db'
location: location
tags: tags
sku: {
name: environment == 'prod' ? 'S1' : 'Basic'
}
}

// Key Vault
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: '${prefix}-kv'
location: location
tags: tags
properties: {
sku: { family: 'A', name: 'standard' }
tenantId: subscription().tenantId
enableRbacAuthorization: true
}
}

// Store SQL connection string in Key Vault
resource sqlSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
parent: keyVault
name: 'sql-connection-string'
properties: {
value: 'Server=tcp:${sqlServer.properties.fullyQualifiedDomainName},1433;Database=${sqlDb.name};User ID=sqladmin;Password=${sqlAdminPassword};Encrypt=true;'
}
}

// Web App
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
name: '${prefix}-app'
location: location
tags: tags
identity: { type: 'SystemAssigned' }
properties: {
serverFarmId: appServicePlan.id
httpsOnly: true
siteConfig: {
minTlsVersion: '1.2'
ftpsState: 'Disabled'
appSettings: [
{ name: 'KEY_VAULT_URI', value: keyVault.properties.vaultUri }
]
}
}
}

output webAppUrl string = 'https://${webApp.properties.defaultHostName}'
output keyVaultUri string = keyVault.properties.vaultUri
# Deploy the complete stack
az deployment group create \
--resource-group rg-prod \
--template-file main.bicep \
--parameters environment=prod sqlAdminPassword='<your-secure-password>'

CI/CD Integration

Add Bicep deployments to your GitHub Actions workflow:

# .github/workflows/deploy.yml
name: Deploy Infrastructure
on:
push:
branches: [main]
paths: ['infra/**']

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: What-If
uses: azure/arm-deploy@v2
with:
resourceGroupName: rg-prod
template: ./infra/main.bicep
parameters: environment=prod
additionalArguments: --what-if
- name: Deploy
uses: azure/arm-deploy@v2
with:
resourceGroupName: rg-prod
template: ./infra/main.bicep
parameters: environment=prod

Wrapping Up

Bicep is the clear path forward for Azure IaC. It compiles to the same ARM JSON but gives you readable syntax, real modules, type safety, and tooling that actually helps. Start by decompiling your existing ARM templates to Bicep with az bicep decompile. Build new infrastructure in Bicep from day one. Use modules for reusable components and the what-if command before every production deployment. If you have been avoiding ARM templates because of the JSON complexity, Bicep removes that excuse entirely.


Next up: We will cover Azure Site Recovery — building a disaster recovery strategy that actually works when production goes down, with replication, failover, and recovery plans.