Azure Bicep
Hands-On Session
From installation to deploying real infrastructure — VNet, NSG, Storage, and reusable modules.
Part 1 Setup
Step 1 — Install Azure CLI
brew update && brew install azure-cli
az version
winget install -e --id Microsoft.AzureCLI
az version
Step 2 — Install Bicep CLI
az bicep install
az bicep version
Step 3 — VS Code Extension
Install the Bicep extension by Microsoft. Gives you IntelliSense, inline errors, resource snippets, and parameter hints.
code --install-extension ms-azuretools.vscode-bicep
Step 4 — Login to Azure
az login
# Set your subscription
az account set --subscription "<your-subscription-id>"
# Confirm active subscription
az account show --query "{name:name, id:id}" -o table
Step 5 — Create Working Directory
mkdir bicep-lab && cd bicep-lab
az bicep version — if it returns a version number, you're set for the labs.Part 2 Bicep Basics
Bicep is a domain-specific language (DSL) that compiles to ARM JSON. It is Azure-native, declarative, and dramatically simpler than writing raw ARM templates.
param
Inputs to your template. Environment-specific values like names, SKUs, locations.
var
Computed expressions used internally. Never hardcode in resource blocks.
resource
The Azure resource you want to deploy. Maps 1:1 to an ARM resource type.
output
Values returned after deployment — IDs, endpoints, names for use in pipelines.
Parameters & Variables
// Parameters — inputs
@description('Azure region')
param location string = 'uaenorth'
@minLength(3)
@maxLength(24)
param storageAccountName string
@allowed(['dev', 'staging', 'prod'])
param environment string = 'dev'
// Variables — computed values
var prefix = 'dpw'
var skuName = environment == 'prod' ? 'Standard_GRS' : 'Standard_LRS'
Resource Block
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
sku: {
name: skuName
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
supportsHttpsTrafficOnly: true
}
tags: {
environment: environment
managedBy: 'bicep'
}
}
Bicep vs ARM JSON — Side by Side
ARM JSON
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-01-01",
"name": "[parameters('storageAccountName')]",
"location": "[parameters('location')]",
"sku": { "name": "Standard_GRS" },
"kind": "StorageV2"
}
Bicep — same result
resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
sku: { name: 'Standard_GRS' }
kind: 'StorageV2'
}
Lab 1 Deploy a Storage Account
Create Resource Group
az group create \
--name rg-bicep-lab \
--location uaenorth
Create storage.bicep
@minLength(3)
@maxLength(24)
@description('Storage account name — globally unique, lowercase, no hyphens')
param storageAccountName string
@description('Azure region')
param location string = resourceGroup().location
@description('Environment tag')
@allowed(['dev', 'staging', 'prod'])
param environment string = 'dev'
var skuName = environment == 'prod' ? 'Standard_GRS' : 'Standard_LRS'
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageAccountName
location: location
sku: {
name: skuName
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
supportsHttpsTrafficOnly: true
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
}
tags: {
environment: environment
managedBy: 'bicep'
team: 'cloud-devops'
}
}
output storageAccountId string = storageAccount.id
output storageAccountName string = storageAccount.name
output primaryEndpoint string = storageAccount.properties.primaryEndpoints.blob
Create storage.bicepparam
using './storage.bicep'
param storageAccountName = 'dpwbiceplab001'
param environment = 'dev'
Validate Before Deploying
what-if. It shows exactly what will change — no surprises.# Compile — catches syntax errors
az bicep build --file storage.bicep
# What-if — preview changes without deploying
az deployment group what-if \
--resource-group rg-bicep-lab \
--template-file storage.bicep \
--parameters storage.bicepparam
Deploy
az deployment group create \
--resource-group rg-bicep-lab \
--template-file storage.bicep \
--parameters storage.bicepparam \
--name "deploy-storage-$(date +%Y%m%d%H%M)"
Verify
# Check resource
az storage account show \
--name dpwbiceplab001 \
--resource-group rg-bicep-lab \
--query "{name:name, sku:sku.name, location:location}" \
-o table
# Check deployment outputs
az deployment group show \
--resource-group rg-bicep-lab \
--name "deploy-storage-*" \
--query properties.outputs \
-o json
environment to prod in the param file and run what-if again. Observe the SKU changes from Standard_LRS → Standard_GRS.Lab 2 VNet + Subnet + NSG
This is the exact pattern you deploy before AKS or virtual machines. The key concept here is how Bicep handles resource dependencies automatically.
Create network.bicep
@description('Environment name')
param environment string = 'dev'
@description('Azure region')
param location string = resourceGroup().location
var vnetName = 'vnet-${environment}-uaenorth'
var nsgName = 'nsg-${environment}-appsubnet'
var addressPrefix = '10.10.0.0/16'
var subnetPrefix = '10.10.1.0/24'
// ── NSG ──────────────────────────────────────────────────────
resource nsg 'Microsoft.Network/networkSecurityGroups@2023-09-01' = {
name: nsgName
location: location
properties: {
securityRules: [
{
name: 'allow-https-inbound'
properties: {
priority: 100
direction: 'Inbound'
access: 'Allow'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '443'
sourceAddressPrefix: 'Internet'
destinationAddressPrefix: '*'
}
}
{
name: 'deny-all-inbound'
properties: {
priority: 4096
direction: 'Inbound'
access: 'Deny'
protocol: '*'
sourcePortRange: '*'
destinationPortRange: '*'
sourceAddressPrefix: '*'
destinationAddressPrefix: '*'
}
}
]
}
tags: {
environment: environment
managedBy: 'bicep'
}
}
// ── VNet + Subnet ─────────────────────────────────────────────
resource vnet 'Microsoft.Network/virtualNetworks@2023-09-01' = {
name: vnetName
location: location
properties: {
addressSpace: {
addressPrefixes: [addressPrefix]
}
subnets: [
{
name: 'snet-app'
properties: {
addressPrefix: subnetPrefix
networkSecurityGroup: {
id: nsg.id // Bicep resolves dependency automatically — no dependsOn needed
}
}
}
]
}
tags: {
environment: environment
managedBy: 'bicep'
}
}
// ── Outputs ───────────────────────────────────────────────────
output vnetId string = vnet.id
output vnetName string = vnet.name
output subnetId string = vnet.properties.subnets[0].id
output nsgId string = nsg.id
Deploy
# What-if first
az deployment group what-if \
--resource-group rg-bicep-lab \
--template-file network.bicep \
--parameters environment=dev
# Deploy
az deployment group create \
--resource-group rg-bicep-lab \
--template-file network.bicep \
--parameters environment=dev \
--name "deploy-network-$(date +%Y%m%d%H%M)"
Verify
# VNet
az network vnet show \
--name vnet-dev-uaenorth \
--resource-group rg-bicep-lab \
--query "{name:name, addressSpace:addressSpace.addressPrefixes}" \
-o table
# Subnet — confirm NSG is attached
az network vnet subnet show \
--name snet-app \
--vnet-name vnet-dev-uaenorth \
--resource-group rg-bicep-lab \
--query "{name:name, prefix:addressPrefix, nsg:networkSecurityGroup.id}" \
-o table
# NSG rules
az network nsg rule list \
--nsg-name nsg-dev-appsubnet \
--resource-group rg-bicep-lab \
-o table
id: nsg.id is a symbolic reference. Bicep sees that VNet depends on NSG and automatically deploys NSG first — no dependsOn array needed. This is one of Bicep's biggest wins over ARM JSON.Part 3 Modules & Best Practices
Until now everything lived in one file. In real projects you split by concern — one module per resource type. A main.bicep orchestrates them. This lab converts your existing files into a proper module structure.
Target Folder Structure
bicep-lab/
├── main.bicep ← orchestrator — calls all modules
├── main.bicepparam ← single param file for everything
└── modules/
├── storage.bicep ← storage account module
└── network.bicep ← vnet + nsg module
Step 1 — Create the modules folder
mkdir modules
mv storage.bicep modules/storage.bicep
mv network.bicep modules/network.bicep
modules/storage.bicep and modules/network.bicep are identical to what you wrote in Labs 1 and 2. Modules are just regular .bicep files — the caller decides how they are used.Step 2 — Create main.bicep
This is the only new file. It declares shared params and calls each module — outputs from one module can feed into another.
// ── Shared parameters ────────────────────────────────────────
@description('Target environment')
@allowed(['dev', 'staging', 'prod'])
param environment string = 'dev'
@description('Azure region for all resources')
param location string = 'uaenorth'
// ── Storage module ────────────────────────────────────────────
module storage './modules/storage.bicep' = {
name: 'deploy-storage'
params: {
storageAccountName: 'dpwstorage${environment}001'
environment: environment
location: location
}
}
// ── Network module ────────────────────────────────────────────
module network './modules/network.bicep' = {
name: 'deploy-network'
params: {
environment: environment
location: location
}
}
// ── Outputs — chain results from both modules ─────────────────
output storageEndpoint string = storage.outputs.primaryEndpoint
output storageName string = storage.outputs.storageAccountName
output vnetId string = network.outputs.vnetId
output subnetId string = network.outputs.subnetId
output nsgId string = network.outputs.nsgId
Step 3 — Create main.bicepparam
One param file to rule them all — no more separate param files per module.
using './main.bicep'
param environment = 'dev'
param location = 'uaenorth'
Step 4 — Validate the module structure compiles
# Compile main — resolves all module references
az bicep build --file main.bicep
# You should see: main.json generated
# Open main.json to see how Bicep inlines all modules into one ARM template
ls -lh main.json
Step 5 — What-if the full deployment
az deployment group what-if \
--resource-group rg-bicep-lab \
--template-file main.bicep \
--parameters main.bicepparam
NoChange — proving Bicep is idempotent.Step 6 — Deploy from main
az deployment group create \
--resource-group rg-bicep-lab \
--template-file main.bicep \
--parameters main.bicepparam \
--name "deploy-main-$(date +%Y%m%d%H%M)"
Step 7 — Verify outputs are chained correctly
# View all outputs from the deployment
az deployment group show \
--resource-group rg-bicep-lab \
--name "deploy-main-*" \
--query "properties.outputs" \
-o json
# Expected outputs:
# storageEndpoint → https://dpwstoragedev001.blob.core.windows.net/
# storageName → dpwstoragedev001
# vnetId → /subscriptions/.../virtualNetworks/vnet-dev-uaenorth
# subnetId → /subscriptions/.../subnets/snet-app
# nsgId → /subscriptions/.../networkSecurityGroups/nsg-dev-appsubnet
Step 8 — Add a third module (extend the pattern)
Now that the structure is in place, adding a new resource is just three things: write a module file, add a module block in main.bicep, expose its outputs.
param environment string
param location string = resourceGroup().location
var kvName = 'kv-${environment}-${uniqueString(resourceGroup().id)}'
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: kvName
location: location
properties: {
sku: {
family: 'A'
name: 'standard'
}
tenantId: subscription().tenantId
enableSoftDelete: true
softDeleteRetentionInDays: 90
enableRbacAuthorization: true // use RBAC instead of access policies
}
tags: {
environment: environment
managedBy: 'bicep'
}
}
output keyVaultId string = keyVault.id
output keyVaultName string = keyVault.name
output keyVaultUri string = keyVault.properties.vaultUri
Add to main.bicep:
module keyVault './modules/keyvault.bicep' = {
name: 'deploy-keyvault'
params: {
environment: environment
location: location
}
}
// Add to outputs section
output keyVaultUri string = keyVault.outputs.keyVaultUri
modules/, one block in main.bicep, outputs wired through.CLI Quick Reference
# Decompile ARM JSON → Bicep
az bicep decompile --file template.json
# Generate param file from bicep
az bicep generate-params --file main.bicep
# Lint — best practice checks
az bicep lint --file main.bicep
# List all deployments
az deployment group list \
--resource-group rg-bicep-lab \
--query "[].{name:name, state:properties.provisioningState, time:properties.timestamp}" \
-o table
# Cleanup
az group delete --name rg-bicep-lab --yes --no-wait
Best Practices
Always run
what-if before every prod deployment.Use
@secure() for passwords and connection strings — never hardcode.Tag every resource:
environment, managedBy, team.One resource type per module file — keeps modules reusable across projects.
main.bicep should only contain module blocks and shared params — no raw resources.Bicep deployments are idempotent — running the same template twice is always safe.
Summary What You Covered
| What you did | Bicep concept used |
|---|---|
| Deployed storage with environment-aware SKU | param, var, conditional expressions |
| Validated before deploying | what-if, lint, build |
| Deployed VNet + NSG with auto-dependency | Symbolic references, implicit dependsOn |
| Chained resources via outputs | output, resource.property |
| Split into reusable files | Modules, module.outputs.x |
| Handled secrets safely | @secure(), Key Vault reference |