Azure Bicep

A software company I worked with had a test environment that behaved perfectly and a production environment that kept failing in strange ways. The cause took two days to find. Someone had clicked through the Azure portal to build production six months earlier and had skipped a network setting that test had. Nobody had written down the steps, and nobody could say what else was different.

That is the problem Azure Bicep solves. Bicep is Microsoft’s language for describing Azure resources in a file, so every deployment produces the same result. You review the file in a pull request, deploy it from a pipeline, and fix mistakes by editing text instead of hunting through portal blades.

This tutorial takes you from installing the tools to deploying a secure web application with a pipeline. You will write parameters, modules, and role assignments, preview changes safely, and troubleshoot the errors that trip up most beginners.

What Is Azure Bicep and Why Use It?

Bicep is a declarative language for deploying Azure resources. Declarative means you describe the end state you want, such as “one storage account with these settings,” and Azure works out how to get there.

Bicep files compile into ARM template JSON, and Azure Resource Manager (ARM), Azure’s deployment and management layer, runs them. If you want that background, see the difference between a resource group and Resource Manager.

Bicep has some practical advantages over clicking in the portal or writing raw JSON:

  • Readable syntax. A typical resource takes a few lines, with no curly-brace noise from JSON.
  • Repeatability. The same file deploys dev, test, and production with different parameter values.
  • Preview before change. The what-if operation predicts what a deployment will change without touching anything.
  • No state file to manage. Azure itself tracks the resources, so you do not store or lock a separate state file.
  • Code review. Infrastructure changes go through the same pull request process as application code.

Here is how Bicep compares with the main alternatives:

OptionStrengthTrade-off
Azure portalFast for learningNot repeatable, hard to audit
Azure CLI or PowerShell scriptsFlexible, proceduralYou write the “how,” and re-runs can fail
ARM JSON templatesNative to AzureVerbose and hard to read
BicepConcise, Azure-nativeAzure only
TerraformMulti-cloud, large ecosystemNeeds state management and extra tooling

If your organization is all in on Azure, Bicep is usually the lowest-friction choice. If you manage several clouds, Terraform may fit better. For the surrounding governance design, read about the Azure landing zone.

Pro Tip: In my experience, teams adopt Bicep fastest when they convert one real environment, not a toy example. Pick the environment that causes the most “why is this different?” tickets, and codify that first.

Set Up Your Azure Bicep Tools

You need the Azure CLI and the Bicep tooling. If you do not have the CLI yet, follow how to install Azure CLI on Windows, or see what the Azure CLI is and keep it current with how to update Azure CLI.

az login
az bicep install
az bicep version

Here is what each command does:

  • az login signs you in. See the az login guide if you hit sign-in issues.
  • az bicep install installs the Bicep CLI that the Azure CLI uses.
  • az bicep version prints the installed version so you can confirm it works.

Install the Bicep extension for Visual Studio Code. It gives you syntax checking, auto-complete for resource properties, and quick fixes. To work with the CLI inside the editor, see how to use Azure CLI in Visual Studio Code.

Use a simple folder layout that grows with you:

infra/
main.bicep
dev.bicepparam
prod.bicepparam
modules/
storage.bicep

main.bicep holds the main deployment. The .bicepparam files hold values for each environment. The modules folder holds reusable pieces.

Pro Tip: I always run az bicep lint before committing. The linter catches unused parameters, hardcoded locations, and risky patterns before a reviewer has to.

Azure Bicep Basics: Write Your First Template

The scenario is an internal employee portal. It needs a web app, a place for secrets, monitoring, and file storage. We will build it so that the app reads secrets through a managed identity, which lets an Azure service authenticate without a stored password. Read what a managed identity in Azure is if the idea is new.

A Bicep file has five main parts: parameters (inputs), variables (computed values), resources (what you create), modules (reusable files), and outputs (values returned after deployment).

Here is main.bicep:

@description('Short workload name, such as portal')
@minLength(3)
@maxLength(11)
param workloadName string

@allowed([
'dev'
'test'
'prod'
])
param environmentName string

param location string = resourceGroup().location

@allowed([
'B1'
'P1v3'
])
param appServiceSku string = 'B1'

param tags object = {}

var suffix = take(uniqueString(resourceGroup().id), 6)
var storageAccountName = toLower('st${workloadName}${environmentName}${suffix}')
var keyVaultName = 'kv-${workloadName}-${environmentName}-${take(suffix, 4)}'
var storageSku = environmentName == 'prod' ? 'Standard_ZRS' : 'Standard_LRS'
var kvSecretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6'

resource logWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
name: 'log-${workloadName}-${environmentName}'
location: location
tags: tags
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
}
}

resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
name: 'appi-${workloadName}-${environmentName}'
location: location
kind: 'web'
tags: tags
properties: {
Application_Type: 'web'
WorkspaceResourceId: logWorkspace.id
}
}

resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
tags: tags
sku: {
name: storageSku
}
kind: 'StorageV2'
properties: {
allowBlobPublicAccess: false
minimumTlsVersion: 'TLS1_2'
supportsHttpsTrafficOnly: true
}
}

resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: keyVaultName
location: location
tags: tags
properties: {
tenantId: subscription().tenantId
sku: {
family: 'A'
name: 'standard'
}
enableRbacAuthorization: true
enableSoftDelete: true
softDeleteRetentionInDays: 90
}
}

resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
name: 'asp-${workloadName}-${environmentName}'
location: location
tags: tags
kind: 'linux'
sku: {
name: appServiceSku
}
properties: {
reserved: true
}
}

resource site 'Microsoft.Web/sites@2023-12-01' = {
name: 'app-${workloadName}-${environmentName}-${suffix}'
location: location
tags: tags
identity: {
type: 'SystemAssigned'
}
properties: {
serverFarmId: plan.id
httpsOnly: true
siteConfig: {
linuxFxVersion: 'NODE|20-lts'
minTlsVersion: '1.2'
ftpsState: 'Disabled'
appSettings: [
{
name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
value: appInsights.properties.ConnectionString
}
{
name: 'KEY_VAULT_URI'
value: keyVault.properties.vaultUri
}
]
}
}
}

resource kvRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: keyVault
name: guid(keyVault.id, site.id, kvSecretsUserRoleId)
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', kvSecretsUserRoleId)
principalId: site.identity.principalId
principalType: 'ServicePrincipal'
}
}

output webAppName string = site.name
output keyVaultUri string = keyVault.properties.vaultUri

That is a lot of code, so here is what each block does and why it matters:

  • Parameter decorators such as @allowed and @minLength reject bad input before Azure creates anything. This catches typos like prdo at deployment time.
  • uniqueString(resourceGroup().id) creates a stable suffix, which avoids name collisions on globally unique resources such as storage accounts and web apps. Follow a clear Azure naming convention so names stay readable.
  • storageSku uses zone-redundant storage in production and locally redundant storage elsewhere. One expression gives each environment the redundancy it needs, which keeps dev costs lower.
  • The storage account blocks anonymous blob access and requires TLS 1.2 and HTTPS. See the Azure storage account tutorial for more background.
  • Log Analytics and Application Insights collect logs and application telemetry from day one. Learn what Azure Monitor does.
  • The Key Vault uses Azure RBAC for access (enableRbacAuthorization) instead of older access policies, and keeps soft delete on. Azure Key Vault stores secrets, keys, and certificates. See how Azure Key Vault works.
  • The web app requires HTTPS, disables FTP, and has a system-assigned identity. Read what Azure App Service is for the hosting model.
  • The role assignment gives only that app’s identity the Key Vault Secrets User role on that one vault. The guid() function creates a deterministic name, so reruns do not create duplicates. This is least privilege: the app can read secrets, not manage them.
  • Outputs return only non-sensitive values. Never output a password, key, or connection string, because outputs appear in deployment history.

The public network setting is left at its defaults here to keep the first deployment simple. For sensitive data in production, add a private endpoint, which gives a service a private IP address inside your virtual network, then disable public access. See how to create a private endpoint in Azure.

Pro Tip: I keep role GUIDs in named variables with a comment. A raw GUID in a resource block means nothing to a reviewer, and a wrong GUID can quietly grant far more access than intended.

Use Parameter Files for Each Environment

Do not edit the template for each environment. Use a .bicepparam file instead. Here is dev.bicepparam:

using 'main.bicep'

param workloadName = 'portal'
param environmentName = 'dev'
param appServiceSku = 'B1'
param tags = {
owner: 'it-team'
costcenter: 'hr'
environment: 'dev'
}

The using statement links the file to its template, so you can deploy with --parameters alone. Create a prod.bicepparam with environmentName = 'prod' and appServiceSku = 'P1v3'. The template stays identical. Only the inputs change, which is the whole point of repeatable deployments. Good Azure tags make later cost reviews easy.