Azure Bicep
Hands-On Session

From installation to deploying real infrastructure — VNet, NSG, Storage, and reusable modules.

1 hour 🧑‍💻 Hands-on labs ☁️ UAE North 🔧 Mac / Windows
0:00 – 0:10
Setup & Install
0:10 – 0:20
Bicep Basics
0:20 – 0:35
Lab 1 — Storage
0:35 – 0:50
Lab 2 — Network
0:50 – 1:00
Modules & Tips

Part 1 Setup

Step 1 — Install Azure CLI

Mac — Homebrew
brew update && brew install azure-cli
az version
Windows — PowerShell (as Admin)
winget install -e --id Microsoft.AzureCLI
az version

Step 2 — Install Bicep CLI

bash / zsh
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.

bash
code --install-extension ms-azuretools.vscode-bicep

Step 4 — Login to Azure

bash
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

bash
mkdir bicep-lab && cd bicep-lab
Ready to goRun 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

bicep
// 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

bicep
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'
}
💡
Key insightBoth produce identical ARM JSON under the hood. Bicep is just a cleaner way to write it — about 60% less code with no loss of functionality.

Lab 1 Deploy a Storage Account

Create Resource Group

bash
az group create \
  --name rg-bicep-lab \
  --location uaenorth

Create storage.bicep

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

storage.bicepparam
using './storage.bicep'

param storageAccountName = 'dpwbiceplab001'
param environment = 'dev'

Validate Before Deploying

⚠️
Always run what-if firstNever deploy to prod without running what-if. It shows exactly what will change — no surprises.
bash
# 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

bash
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

bash
# 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
🎯
Quick exerciseChange environment to prod in the param file and run what-if again. Observe the SKU changes from Standard_LRSStandard_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

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

bash
# 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

bash
# 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
💡
Key observationid: 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

tree
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

bash
mkdir modules
mv storage.bicep modules/storage.bicep
mv network.bicep modules/network.bicep
💡
What changes inside module files? Nothing. 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.

main.bicep
// ── 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.

main.bicepparam
using './main.bicep'

param environment = 'dev'
param location    = 'uaenorth'

Step 4 — Validate the module structure compiles

bash
# 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

bash
az deployment group what-if \
  --resource-group rg-bicep-lab \
  --template-file main.bicep \
  --parameters main.bicepparam
⚠️
What to look for in what-if output You should see two resource changes — the storage account and the VNet+NSG. If you already deployed them in Labs 1 and 2, what-if will show NoChange — proving Bicep is idempotent.

Step 6 — Deploy from main

bash
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

bash
# 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.

modules/keyvault.bicep
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:

main.bicep — add this block
module keyVault './modules/keyvault.bicep' = {
  name: 'deploy-keyvault'
  params: {
    environment: environment
    location: location
  }
}

// Add to outputs section
output keyVaultUri string = keyVault.outputs.keyVaultUri
Pattern complete You now have a production-ready module structure. Every new resource follows the same pattern — one file in modules/, one block in main.bicep, outputs wired through.

CLI Quick Reference

bash
# 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

Rules to follow from day one

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 didBicep concept used
Deployed storage with environment-aware SKUparam, var, conditional expressions
Validated before deployingwhat-if, lint, build
Deployed VNet + NSG with auto-dependencySymbolic references, implicit dependsOn
Chained resources via outputsoutput, resource.property
Split into reusable filesModules, module.outputs.x
Handled secrets safely@secure(), Key Vault reference

Next Steps

Deploy AKS via Bicep

Add AKS cluster to your main.bicep — use the subnetId output from the network module.

Bicep in Azure DevOps

Use the AzureResourceManagerTemplateDeployment task — just point it at your .bicep file.

Bicep Registry Modules

Pre-built, tested modules for AKS, App Service, APIM and more at aka.ms/bicep-registry.

Existing Resources

Use the existing keyword to reference resources you didn't create — Key Vault, VNets, ACR.