Intermediate

Azure Infrastructure as Code with ARM and Bicep

Author infrastructure with ARM and Bicep, deploy via CLI/PowerShell and detect drift.

Level: Intermediate
Estimated duration: 4–5 hours
Last updated: June 2026

Table of Contents

  1. Introduction to IaC with Azure
  2. Module 1 – ARM Templates and Bicep
  3. Module 2 – Deployments with Azure CLI and PowerShell
  4. Module 3 – What-if and Change Tracking
  5. Module 4 – Testing and Drift Detection
  6. Complete Code Examples
  7. Comparison Tables
  8. Glossary

Introduction to IaC with Azure

What is Infrastructure as Code?

Infrastructure as Code (IaC) is the practice of defining and managing IT infrastructure (servers, networks, databases, etc.) through source code rather than through graphical interfaces or manual processes. This approach brings:

  • Reproducibility: deploy exactly the same infrastructure again and again
  • Versioning: change history in Git, rollback possible
  • Collaboration: infrastructure changes go through Pull Requests
  • Automation: integration into CI/CD pipelines
  • Living documentation: the code is the documentation of the infrastructure
  • Drift elimination: the state declared in code must match the real state

IaC Workflow with Azure

flowchart LR
    subgraph CODE["IaC Development"]
        BICEP[".bicep file\nor ARM JSON"]
        PARAMS["Parameter\nfile"]
        GIT["Git Repository\nVersioning"]
    end
    
    subgraph VALIDATION["Validation"]
        LINT["az bicep lint\nBicep Linter"]
        TTK["ARM-TTK\nBest practices"]
        WHATIF["What-if\nDry run"]
    end
    
    subgraph DEPLOY["Deployment"]
        ARM["Azure Resource\nManager API"]
        PROVIDERS["Resource\nProviders"]
        RESOURCES["Azure Resources\ncreated"]
    end
    
    subgraph MONITOR["Monitoring"]
        DRIFT["Drift Detection\nExport + Compare"]
        CHANGE["Change Analysis\nHistory"]
    end
    
    CODE --> VALIDATION
    VALIDATION --> DEPLOY
    DEPLOY --> MONITOR
    MONITOR -->|Feedback| CODE
    
    style CODE fill:#1e3a5f,color:#fff
    style VALIDATION fill:#f59e0b,color:#000
    style DEPLOY fill:#10b981,color:#fff
    style MONITOR fill:#3b82f6,color:#fff

ARM vs Bicep vs Terraform: When to use which?

CriterionARM (JSON)BicepTerraform
Readability⭐ Low⭐⭐⭐⭐ Excellent⭐⭐⭐ Good
Azure native✅ 100% native✅ 100% native⚠️ Via providers
Multi-cloud❌ Azure only❌ Azure only✅ AWS, GCP, Azure…
Learning curve⭐⭐⭐⭐ High⭐⭐ Moderate⭐⭐⭐ Moderate
ToolingBuilt-in AzureAz CLI, VS Code ext.Terraform CLI
State managementAzure RM manages allAzure RM manages all.tfstate file
Reusable modulesLinked (nested/linked)Modules + AVMModules Registry
RecommendationLegacy, migration✅ New Azure projectsMulti-cloud

Module 1 – ARM Templates and Bicep

ARM Template Structure (JSON)

An ARM template is a JSON file that describes Azure infrastructure declaratively. Azure Resource Manager reads this file and orchestrates resource creation.

graph TD
    subgraph ARM["ARM Template Structure"]
        SCHEMA["$schema\nDefines JSON format\nand template version"]
        PARAMS["parameters\nValues entered at deployment\n(environment, region, names)"]
        VARS["variables\nCalculated values\n(based on params/functions)"]
        FUNCS["functions\nUser-Defined Functions\n(reusable logic)"]
        RESOURCES["resources\nAzure Resources\nto deploy"]
        OUTPUTS["outputs\nReturned values\n(URLs, IDs, etc.)"]
    end
    
    SCHEMA --> PARAMS
    PARAMS --> VARS
    VARS --> FUNCS
    FUNCS --> RESOURCES
    RESOURCES --> OUTPUTS

Main components:

ComponentRequiredDescription
$schema✅ YesDefines JSON format and version — template foundation
contentVersion✅ YesTemplate version (e.g. “1.0.0.0”)
parameters❌ NoValues entered at runtime — make the template reusable
variables❌ NoCalculated values — reduce repetition
functions❌ NoCustom functions (User-Defined Functions)
resources✅ YesAzure resources to deploy (can be empty)
outputs❌ NoValues returned after deployment (URL, IDs)

Complete ARM Template: Storage Account

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  
  "parameters": {
    "location": {
      "type": "string",
      "defaultValue": "[resourceGroup().location]",
      "metadata": {
        "description": "Resource location. Defaults to the Resource Group region."
      }
    },
    "environment": {
      "type": "string",
      "allowedValues": ["dev", "staging", "prod"],
      "defaultValue": "dev",
      "metadata": {
        "description": "Deployment environment."
      }
    },
    "storageSkuName": {
      "type": "string",
      "defaultValue": "Standard_LRS",
      "allowedValues": [
        "Standard_LRS",
        "Standard_GRS",
        "Standard_ZRS",
        "Premium_LRS"
      ],
      "metadata": {
        "description": "Storage account SKU."
      }
    }
  },
  
  "variables": {
    "storageAccountName": "[toLower(concat(parameters('environment'), 'storage', uniqueString(resourceGroup().id)))]",
    "tags": {
      "Environment": "[parameters('environment')]",
      "ManagedBy": "ARM Template",
      "DeployedAt": "[utcNow()]"
    }
  },
  
  "functions": [
    {
      "namespace": "contoso",
      "members": {
        "uniqueName": {
          "parameters": [
            {
              "name": "namePrefix",
              "type": "string"
            }
          ],
          "output": {
            "type": "string",
            "value": "[toLower(concat(parameters('namePrefix'), uniqueString(resourceGroup().id)))]"
          }
        }
      }
    }
  ],
  
  "resources": [
    {
      "type": "Microsoft.Storage/storageAccounts",
      "apiVersion": "2023-01-01",
      "name": "[variables('storageAccountName')]",
      "location": "[parameters('location')]",
      "tags": "[variables('tags')]",
      "sku": {
        "name": "[parameters('storageSkuName')]"
      },
      "kind": "StorageV2",
      "properties": {
        "accessTier": "Hot",
        "supportsHttpsTrafficOnly": true,
        "minimumTlsVersion": "TLS1_2",
        "allowBlobPublicAccess": false,
        "networkAcls": {
          "defaultAction": "Deny",
          "bypass": "AzureServices"
        }
      }
    }
  ],
  
  "outputs": {
    "storageAccountName": {
      "type": "string",
      "value": "[variables('storageAccountName')]"
    },
    "storageAccountId": {
      "type": "string",
      "value": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]"
    },
    "primaryEndpoints": {
      "type": "object",
      "value": "[reference(variables('storageAccountName')).primaryEndpoints]"
    }
  }
}

Built-in ARM Functions

// String functions
"[toLower('MyString')]"                            // "mystring"
"[concat('prefix-', parameters('name'))]"          // "prefix-value"
"[uniqueString(resourceGroup().id)]"               // Unique 13-char hash
"[replace('hello-world', '-', '_')]"               // "hello_world"

// Resource functions
"[resourceId('Microsoft.Storage/storageAccounts', 'myaccount')]"
"[resourceGroup().name]"                           // Resource group name
"[resourceGroup().location]"                       // Resource group region
"[subscription().subscriptionId]"                  // Subscription ID

// Numeric functions
"[add(parameters('instances'), 1)]"               // instances + 1
"[mul(parameters('size'), 2)]"                    // size * 2

// Conditional functions
"[if(equals(parameters('env'), 'prod'), 'Premium_LRS', 'Standard_LRS')]"

// Array functions
"[first(parameters('regions'))]"                  // First element
"[last(parameters('regions'))]"                   // Last element
"[length(parameters('regions'))]"                 // Array length

Bicep Templates

Why Bicep?

Bicep is a DSL (Domain-Specific Language) created by Microsoft to replace ARM JSON templates in development workflows. It is more readable, more concise, and offers a better developer experience while generating the exact same underlying ARM JSON.

graph LR
    subgraph BICEP[".bicep File"]
        B1["Readable syntax\nNo verbose JSON"]
        B2["IntelliSense\nVS Code Extension"]
        B3["Reusable modules\nAzure Verified Modules"]
    end
    
    subgraph TRANSPILE["Transpilation"]
        T1["az bicep build\nOR az deployment group create"]
    end
    
    subgraph ARM["ARM JSON"]
        A1["Full verbose\nJSON format"]
    end
    
    subgraph AZURE["Azure Resource Manager"]
        AZ1["Resource\norchestration"]
    end
    
    BICEP --> TRANSPILE
    TRANSPILE --> ARM
    ARM --> AZURE
    
    style BICEP fill:#8b5cf6,color:#fff
    style TRANSPILE fill:#f59e0b,color:#000
    style ARM fill:#6b7280,color:#fff
    style AZURE fill:#1e40af,color:#fff

ARM JSON vs Bicep comparison for the same Storage Account:

// ARM JSON (35 lines)
{
  "type": "Microsoft.Storage/storageAccounts",
  "apiVersion": "2023-01-01",
  "name": "[variables('storageAccountName')]",
  "location": "[parameters('location')]",
  "sku": {
    "name": "[parameters('storageSkuName')]"
  },
  "kind": "StorageV2",
  "properties": {
    "accessTier": "Hot",
    "supportsHttpsTrafficOnly": true
  }
}
// Equivalent Bicep (10 lines)
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: storageSkuName
  }
  kind: 'StorageV2'
  properties: {
    accessTier: 'Hot'
    supportsHttpsTrafficOnly: true
  }
}

Complete Bicep Template: Web Infrastructure

// main.bicep - Complete infrastructure: App Service + Storage + Key Vault

// ===== PARAMETERS =====

@description('Deployment environment')
@allowed(['dev', 'staging', 'prod'])
param environment string = 'dev'

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

@description('App Service plan SKU')
@allowed(['F1', 'B1', 'B2', 'S1', 'S2', 'P1v3', 'P2v3'])
param appServicePlanSku string = environment == 'prod' ? 'P1v3' : 'B1'

@description('Number of App Service replicas')
@minValue(1)
@maxValue(10)
param appServiceInstances int = environment == 'prod' ? 3 : 1

@description('Enable access logs')
param enableAccessLogs bool = true

// ===== VARIABLES =====

var uniqueSuffix = uniqueString(resourceGroup().id)
var storageAccountName = '${environment}storage${uniqueSuffix}'
var keyVaultName = '${environment}-kv-${uniqueSuffix}'
var appServicePlanName = '${environment}-asp-${uniqueSuffix}'
var webAppName = '${environment}-webapp-${uniqueSuffix}'

var commonTags = {
  Environment: environment
  ManagedBy: 'Bicep'
  DeployedAt: utcNow()
  Repository: 'https://github.com/my-org/my-repo'
}

// ===== RESOURCES =====

// Storage Account
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  tags: commonTags
  sku: {
    name: environment == 'prod' ? 'Standard_ZRS' : 'Standard_LRS'
  }
  kind: 'StorageV2'
  properties: {
    accessTier: 'Hot'
    supportsHttpsTrafficOnly: true
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
    networkAcls: {
      defaultAction: 'Deny'
      bypass: 'AzureServices'
    }
  }
}

// Azure Key Vault
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
  name: keyVaultName
  location: location
  tags: commonTags
  properties: {
    sku: {
      family: 'A'
      name: 'standard'
    }
    tenantId: subscription().tenantId
    enableRbacAuthorization: true
    enableSoftDelete: true
    softDeleteRetentionInDays: 90
    enablePurgeProtection: environment == 'prod' ? true : null
    networkAcls: {
      defaultAction: 'Deny'
      bypass: 'AzureServices'
    }
  }
}

// App Service Plan
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
  name: appServicePlanName
  location: location
  tags: commonTags
  sku: {
    name: appServicePlanSku
    capacity: appServiceInstances
  }
  kind: 'linux'
  properties: {
    reserved: true  // Required for Linux
  }
}

// Web App
resource webApp 'Microsoft.Web/sites@2023-01-01' = {
  name: webAppName
  location: location
  tags: commonTags
  identity: {
    type: 'SystemAssigned'  // Managed Identity
  }
  properties: {
    serverFarmId: appServicePlan.id
    httpsOnly: true
    siteConfig: {
      linuxFxVersion: 'NODE|20-lts'
      alwaysOn: environment == 'prod' ? true : false
      ftpsState: 'Disabled'
      http20Enabled: true
      minTlsVersion: '1.2'
      appSettings: [
        {
          name: 'STORAGE_ACCOUNT_NAME'
          value: storageAccount.name
        }
        {
          name: 'KEY_VAULT_URI'
          value: keyVault.properties.vaultUri
        }
        {
          name: 'NODE_ENV'
          value: environment
        }
      ]
    }
  }
}

// Assign Key Vault Secrets User role to the Web App
resource kvRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(keyVault.id, webApp.id, 'Key Vault Secrets User')
  scope: keyVault
  properties: {
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      '4633458b-17de-408a-b874-0445c86b69e6'  // Key Vault Secrets User role ID
    )
    principalId: webApp.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

// Diagnostic settings (logs to Log Analytics) - if prod
resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableAccessLogs) {
  name: 'webapp-diagnostics'
  scope: webApp
  properties: {
    logs: [
      {
        category: 'AppServiceHTTPLogs'
        enabled: true
        retentionPolicy: {
          enabled: true
          days: environment == 'prod' ? 90 : 30
        }
      }
      {
        category: 'AppServiceConsoleLogs'
        enabled: true
        retentionPolicy: {
          enabled: true
          days: 30
        }
      }
    ]
    metrics: [
      {
        category: 'AllMetrics'
        enabled: true
        retentionPolicy: {
          enabled: true
          days: 30
        }
      }
    ]
  }
}

// ===== OUTPUTS =====

output webAppName string = webApp.name
output webAppUrl string = 'https://${webApp.properties.defaultHostName}'
output webAppPrincipalId string = webApp.identity.principalId
output storageAccountName string = storageAccount.name
output keyVaultName string = keyVault.name
output keyVaultUri string = keyVault.properties.vaultUri

Bicep Parameters File (.bicepparam)

// main.bicepparam - Parameter file for the production environment

using './main.bicep'

param environment = 'prod'
param location = 'eastus'
param appServicePlanSku = 'P1v3'
param appServiceInstances = 3
param enableAccessLogs = true

Bicep Modules

Modules allow splitting templates into reusable components and promote separation of concerns.

graph TD
    MAIN["main.bicep\n(Main template)"]
    
    subgraph MODULES["Modules"]
        M1["storage.bicep\nStorage Account"]
        M2["keyvault.bicep\nAzure Key Vault"]
        M3["webapp.bicep\nApp Service"]
        M4["br/public:\nstorage-account:0.4.1\n(Azure Verified Module)"]
    end
    
    MAIN -->|"module storageModule"| M1
    MAIN -->|"module kvModule"| M2
    MAIN -->|"module appModule"| M3
    MAIN -->|"module avm"| M4
    
    style MAIN fill:#1e40af,color:#fff
    style MODULES fill:#0f4c75,color:#fff
    style M4 fill:#7c3aed,color:#fff

Example using custom modules:

// main.bicep with modules

param environment string = 'dev'
param location string = resourceGroup().location

// Storage module (local file)
module storageModule './modules/storage.bicep' = {
  name: 'storageDeployment'
  params: {
    environment: environment
    location: location
  }
}

// Key Vault module (local file)
module kvModule './modules/keyvault.bicep' = {
  name: 'keyVaultDeployment'
  params: {
    environment: environment
    location: location
  }
}

// Azure Verified Module from public registry
module storageAvm 'br/public:storage/storage-account:0.4.1' = {
  name: 'storageAvmDeployment'
  params: {
    name: 'myavmstorage${uniqueString(resourceGroup().id)}'
    location: location
  }
}

// Use outputs from one module in another
output storageAccountId string = storageModule.outputs.storageAccountId
output keyVaultUri string = kvModule.outputs.keyVaultUri

Storage module (modules/storage.bicep):

// modules/storage.bicep

param environment string
param location string

var storageAccountName = '${environment}storage${uniqueString(resourceGroup().id)}'

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: environment == 'prod' ? 'Standard_ZRS' : 'Standard_LRS'
  }
  kind: 'StorageV2'
  properties: {
    accessTier: 'Hot'
    supportsHttpsTrafficOnly: true
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
  }
}

output storageAccountName string = storageAccount.name
output storageAccountId string = storageAccount.id
output primaryBlobEndpoint string = storageAccount.properties.primaryEndpoints.blob

Module 2 – Deployments with Azure CLI and PowerShell

Azure CLI for IaC Deployments

# ===== RESOURCE GROUPS =====

# Create a resource group with tags
az group create \
    --name app-rg \
    --location eastus \
    --tags Environment=dev ManagedBy=Bicep Owner=cloud-team

# List resource groups
az group list --output table

# Delete a resource group and all its resources
az group delete --name app-rg --yes --no-wait

# ===== BICEP DEPLOYMENTS =====

# Deploy a Bicep template
az deployment group create \
    --resource-group app-rg \
    --template-file main.bicep \
    --parameters environment=dev location=eastus

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

# Deploy with .bicepparam file
az deployment group create \
    --resource-group app-rg \
    --template-file main.bicep \
    --parameters main.bicepparam

# Deploy at subscription level
az deployment sub create \
    --location eastus \
    --template-file subscription-level.bicep \
    --parameters @sub-params.json

# ===== VALIDATE A TEMPLATE =====

# Validate without deploying
az deployment group validate \
    --resource-group app-rg \
    --template-file main.bicep \
    --parameters environment=dev

# Compile Bicep → ARM JSON (for review)
az bicep build --file main.bicep --outdir ./compiled

# ===== TRACK DEPLOYMENTS =====

# List deployments in a resource group
az deployment group list \
    --resource-group app-rg \
    --output table

# View details of a deployment
az deployment group show \
    --resource-group app-rg \
    --name main \
    --query "properties.{Status:provisioningState, Duration:duration}" \
    --output table

# View operations of a deployment
az deployment group operation list \
    --resource-group app-rg \
    --name main \
    --output table

Azure PowerShell for IaC Deployments

# ===== RESOURCE GROUPS =====

# Create a resource group
New-AzResourceGroup `
    -Name "app-rg" `
    -Location "eastus" `
    -Tag @{
        Environment = "dev"
        ManagedBy = "Bicep"
        Owner = "cloud-team"
    }

# List resource groups
Get-AzResourceGroup | Format-Table Name, Location, ProvisioningState

# ===== BICEP DEPLOYMENTS =====

# Deploy a Bicep template
New-AzResourceGroupDeployment `
    -ResourceGroupName "app-rg" `
    -TemplateFile "main.bicep" `
    -environment "dev" `
    -location "eastus"

# Deploy with a parameter file
New-AzResourceGroupDeployment `
    -ResourceGroupName "app-rg" `
    -TemplateFile "main.bicep" `
    -TemplateParameterFile "parameters.json"

# Deploy at subscription level
New-AzSubscriptionDeployment `
    -Location "eastus" `
    -TemplateFile "subscription-level.bicep"

# ===== VALIDATE =====

# Validate without deploying
Test-AzResourceGroupDeployment `
    -ResourceGroupName "app-rg" `
    -TemplateFile "main.bicep" `
    -environment "dev"

# ===== TRACK =====

# View operations of a deployment
Get-AzResourceGroupDeploymentOperation `
    -ResourceGroupName "app-rg" `
    -DeploymentName "main" |
    Select-Object OperationId, ProvisioningState, StatusCode |
    Format-Table

Azure CLI vs Azure PowerShell Comparison

OperationAzure CLIAzure PowerShell
Create RGaz group create --name X --location YNew-AzResourceGroup -Name X -Location Y
Deploy Bicepaz deployment group create --template-fileNew-AzResourceGroupDeployment -TemplateFile
What-ifaz deployment group what-ifNew-AzResourceGroupDeployment -WhatIf
Validateaz deployment group validateTest-AzResourceGroupDeployment
List deploymentsaz deployment group listGet-AzResourceGroupDeployment
Delete RGaz group delete --name XRemove-AzResourceGroup -Name X
Loginaz loginConnect-AzAccount
Output format--output table/json/yaml| Format-Table/ConvertTo-Json

Module 3 – What-if and Change Tracking

What-if Deployments: Infrastructure Dry Run

A What-if deployment is the equivalent of a git diff for your infrastructure: it shows you exactly what will change if you deploy, without making any real changes.

sequenceDiagram
    participant DEV as Engineer
    participant CLI as Azure CLI
    participant ARM as Azure Resource Manager
    participant AZURE as Azure Resources

    DEV->>CLI: az deployment group what-if\n--template-file main.bicep
    CLI->>ARM: Deployment simulation
    ARM->>AZURE: Analyze current state
    AZURE-->>ARM: Current resource state
    ARM-->>CLI: Expected changes (JSON)
    CLI-->>DEV: Change report\n(+ create, ~ modify, - delete)
    
    Note over DEV: Review changes
    
    alt Changes approved
        DEV->>CLI: az deployment group create\n--template-file main.bicep
        CLI->>ARM: Real deployment
        ARM->>AZURE: Create/Modify/Delete
        AZURE-->>DEV: Resources deployed ✅
    else Changes not approved
        DEV->>DEV: Modify template
        DEV->>CLI: Re-run what-if
    end

What-if report symbols:

SymbolColorMeaning
+GreenResource/property created
~YellowResource/property modified
-RedResource/property deleted
=GrayNo change detected
*OrangeProperty ignored (not present in template)
# ===== WHAT-IF WITH AZURE CLI =====

# Basic what-if
az deployment group what-if \
    --resource-group app-rg \
    --template-file main.bicep \
    --parameters environment=prod

# What-if with automatic confirmation if changes detected
az deployment group what-if \
    --resource-group app-rg \
    --template-file main.bicep \
    --confirm-with-what-if  # Pauses if changes detected

# What-if with JSON output (for automation/CI)
az deployment group what-if \
    --resource-group app-rg \
    --template-file main.bicep \
    --output json | jq '.properties.changes'

# Example what-if output:
# Resource changes: 2 to create, 1 to modify, 0 to delete.
#
# The deployment will update the following scope:
# Scope: /subscriptions/{sub}/resourceGroups/app-rg
#
#   + Microsoft.Storage/storageAccounts/prodstorage123 [2023-01-01]
#     apiVersion: "2023-01-01"
#     id:         "/subscriptions/.../storageAccounts/prodstorage123"
#     kind:       "StorageV2"
#     location:   "eastus"
#     name:       "prodstorage123"
#
#   ~ Microsoft.Web/sites/prod-webapp-123 [2023-01-01]
#     properties.siteConfig.alwaysOn: false => true
# ===== WHAT-IF WITH POWERSHELL =====

# Basic what-if
New-AzResourceGroupDeployment `
    -ResourceGroupName "app-rg" `
    -TemplateFile "main.bicep" `
    -environment "prod" `
    -WhatIf

# What-if filtering change types (exclude NoChange and Ignore)
New-AzResourceGroupDeployment `
    -ResourceGroupName "app-rg" `
    -TemplateFile "main.bicep" `
    -WhatIf `
    -WhatIfExcludeChangeType NoChange, Ignore

# What-if with minimal format (only IDs of impacted resources)
New-AzResourceGroupDeployment `
    -ResourceGroupName "app-rg" `
    -TemplateFile "main.bicep" `
    -WhatIf `
    -WhatIfResultFormat ResourceIdOnly

# Capture what-if result for analysis
$whatIfResult = New-AzResourceGroupDeployment `
    -ResourceGroupName "app-rg" `
    -TemplateFile "main.bicep" `
    -WhatIf `
    -ResultObject

# Analyze changes
$whatIfResult.Changes | Where-Object { $_.ChangeType -eq 'Delete' } |
    Select-Object ResourceId, ChangeType | Format-Table

Azure Change Analysis: Modification History

Azure Change Analysis allows viewing all changes made to Azure resources over a given period. It is an essential tool for:

  • Understanding what changed after an incident
  • Auditing unplanned modifications
  • Validating that deployments had the intended effect
graph TD
    subgraph SOURCES["Change sources"]
        DEPLOY["ARM/Bicep\nDeployments"]
        PORTAL["Azure portal\nmodifications"]
        CLI["Azure CLI\ncommands"]
        POLICY["Azure Policy\nauto-remediation"]
    end
    
    subgraph STORAGE["Storage"]
        RP["Azure Resource\nManager API"]
        CHANGE_DB["Change Analysis\nDatabase"]
    end
    
    subgraph VISUALIZATION["Visualization"]
        BLADE["Change Analysis\nportal blade"]
        FILTER["Filters:\n- Subscription\n- Resource Group\n- Period\n- Resource type"]
        DETAIL["Detailed view\nModified properties"]
    end
    
    SOURCES --> RP
    RP --> CHANGE_DB
    CHANGE_DB --> BLADE
    BLADE --> FILTER
    FILTER --> DETAIL
    
    style SOURCES fill:#1e3a5f,color:#fff
    style STORAGE fill:#f59e0b,color:#000
    style VISUALIZATION fill:#10b981,color:#fff

Module 4 – Testing and Drift Detection

ARM Template Toolkit (ARM-TTK)

ARM-TTK is an open-source PowerShell module that validates your ARM and Bicep templates against Microsoft best practices. It is the linting/quality assurance tool for Azure IaC.

flowchart TD
    TEMPLATE["ARM/Bicep Template"]
    TTK["ARM-TTK\nTest-AzTemplate"]
    
    subgraph TESTS["ARM-TTK Tests"]
        T1["✅ Location = parameter\nreferenced by all resources"]
        T2["✅ Used parameters/variables\nno orphan declarations"]
        T3["✅ Recent API versions\nno hardcoded obsolete versions"]
        T4["✅ No concat() for IDs\nuse resourceId() instead"]
        T5["✅ Unique names\nno naming collisions"]
        T6["✅ Outputs without secrets\nno passwords in outputs"]
    end
    
    TEMPLATE --> TTK
    TTK --> TESTS
    
    TESTS --> PASS["✅ All tests pass\nValid template"]
    TESTS --> FAIL["❌ Failed tests\nCorrections needed"]
    
    style TEMPLATE fill:#1e3a5f,color:#fff
    style TTK fill:#f59e0b,color:#000
    style PASS fill:#10b981,color:#fff
    style FAIL fill:#ef4444,color:#fff
# ===== ARM-TTK INSTALLATION AND USAGE =====

# Download ARM-TTK from GitHub
# https://github.com/Azure/arm-ttk/releases

# Unzip and import the module
$armTtkPath = "C:\arm-ttk\arm-ttk.psd1"
Import-Module $armTtkPath

# Test an ARM JSON template
Test-AzTemplate -TemplatePath ./main.json

# Test a Bicep template (compiled to ARM before test)
Test-AzTemplate -TemplatePath ./main.bicep

# Test an entire folder
Test-AzTemplate -TemplatePath ./templates/ -Recurse

# Test with filters on specific tests
Test-AzTemplate `
    -TemplatePath ./main.json `
    -Test "apiVersions-should-be-recent"

# Example ARM-TTK output:
# Template    : main.json
#   Pass  : adminUsername Should Not Be A Literal
#   Pass  : artifacts parameter
#   Pass  : CommandToExecute Must Use ProtectedSettings For Secrets
#   FAIL  : DependsOn Best Practices
#     >> main.json (line 45): "dependsOn" should not contain "[concat(...)]"
#   Pass  : Deployment Resources Must Not Be Debug
#   Pass  : DeploymentTemplate Schema Is Correct
#   Pass  : Dynamic Variable References Should Not Use Concat
#   FAIL  : IDs Should Be Derived From ResourceIDs
#     >> main.json (line 78): Use resourceId() instead of concat() for IDs
#   Pass  : Location Should Not Be Hardcoded
#   Pass  : ManagedIdentityExtension must not be used
#   Pass  : Min And Max Value Are The Correct Type
#   Pass  : Outputs Must Not Contain Secrets
#   Pass  : Parameter Types Should Be Consistent
#   Pass  : Parameters Must Be Referenced

# Integration in a CI/CD script
$testResult = Test-AzTemplate -TemplatePath ./main.bicep
$failedTests = $testResult | Where-Object { $_.Passed -eq $false }

if ($failedTests.Count -gt 0) {
    Write-Error "ARM-TTK: $($failedTests.Count) test(s) failed!"
    $failedTests | Format-Table Name, Passed, Errors
    exit 1
}

Write-Output "ARM-TTK: All tests passed!"

Most Important ARM-TTK Rules

RuleDescriptionExample violation
Location must be a parameterLocation must be defined via a parameter, not hardcoded"location": "eastus" instead of "location": "[parameters('location')]"
Recent API versionsDo not use outdated API versionsapiVersion: "2018-02-01" for a resource with a 2023 version
Used parametersEvery declared parameter must be used in the templateDeclaring param dbPassword string without using it
Used variablesSame for variablesVariable storagePrefix declared but never referenced
No concat for IDsUse resourceId() rather than concat() to build resource IDsconcat('/subscriptions/', subscription().id, '...')
Outputs without secretsOutputs must not expose sensitive dataoutput dbPassword string = 'mypassword'
Descriptive parameter namesParameters should have metadata descriptionsParameter without metadata.description

Configuration Drift Detection

Configuration drift occurs when the actual state of resources diverges from the state declared in templates. This happens via:

  • Manual modifications in the Azure portal
  • Emergency fixes via CLI without updating the template
  • Automatic modifications by Azure Policy
  • Auto-scaling or automatic modifications by managed services
# ===== METHOD 1: WHAT-IF ON EXISTING TEMPLATE =====

# Run what-if on an already-deployed template
# If state differs, changes are detected = possible drift
az deployment group what-if \
    --resource-group app-rg \
    --template-file main.bicep \
    --parameters @prod-params.json

# Note: Some "drift" properties are false positives
# (properties added by Azure after deployment)

# ===== METHOD 2: EXPORT + COMPARISON (more accurate) =====

# Step 1: Export current state of the resource group
az group export \
    --name app-rg \
    --include-parameter-default-value \
    > current-state.json

# Step 2: Compile source template to ARM JSON
az bicep build \
    --file main.bicep \
    --outfile desired-state.json

# Step 3: Compare the two files
# Option 1: classic diff
diff <(jq . desired-state.json) <(jq . current-state.json)

# Option 2: VS Code - open both files side by side
code --diff desired-state.json current-state.json

Automated drift detection script:

#!/bin/bash
# drift-detection.sh - Detects configuration drift

RESOURCE_GROUP="production-rg"
TEMPLATE_FILE="main.bicep"
PARAMS_FILE="prod-params.json"

echo "=== Drift Detection: $RESOURCE_GROUP ==="

# Run what-if and capture output
WHATIF_OUTPUT=$(az deployment group what-if \
    --resource-group $RESOURCE_GROUP \
    --template-file $TEMPLATE_FILE \
    --parameters @$PARAMS_FILE \
    --output json 2>&1)

# Check if changes are detected
CHANGES_COUNT=$(echo $WHATIF_OUTPUT | jq '.properties.changes | length')

if [ "$CHANGES_COUNT" -eq "0" ]; then
    echo "✅ No drift detected. Current state matches the template."
else
    echo "⚠️  DRIFT DETECTED! $CHANGES_COUNT change(s) detected:"
    echo $WHATIF_OUTPUT | jq '.properties.changes[] | {Resource: .resourceId, Type: .changeType}'
    
    # Send an alert (example with curl to Teams/Slack)
    # curl -X POST "https://hooks.slack.com/services/..." \
    #     -H 'Content-type: application/json' \
    #     --data "{\"text\":\"⚠️ Configuration drift detected in $RESOURCE_GROUP: $CHANGES_COUNT change(s)\"}"
    
    exit 1
fi

CI/CD Pipeline Integration

# .github/workflows/iac-pipeline.yml

name: Infrastructure as Code Pipeline

on:
  push:
    branches: [main]
    paths:
      - 'infrastructure/**'
  pull_request:
    branches: [main]
    paths:
      - 'infrastructure/**'

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  validate:
    name: Validate Templates
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Azure Login (OIDC)
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      
      - name: Bicep Lint
        run: |
          az bicep install
          az bicep lint --file infrastructure/main.bicep
      
      - name: Validate Deployment
        run: |
          az deployment group validate \
              --resource-group dev-rg \
              --template-file infrastructure/main.bicep \
              --parameters infrastructure/params.dev.json
      
      - name: What-If Analysis
        id: whatif
        run: |
          WHATIF_OUTPUT=$(az deployment group what-if \
              --resource-group ${{ vars.RESOURCE_GROUP }} \
              --template-file infrastructure/main.bicep \
              --parameters infrastructure/params.prod.json \
              --output json)
          
          echo "whatif-output=$WHATIF_OUTPUT" >> $GITHUB_OUTPUT
          
          CHANGES=$(echo $WHATIF_OUTPUT | jq '.properties.changes | length')
          echo "changes-count=$CHANGES" >> $GITHUB_OUTPUT
          
          echo "Change summary:"
          echo $WHATIF_OUTPUT | jq '.properties.changes[] | {resource: .resourceId, type: .changeType}'
      
      - name: Comment PR with What-If Results
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const output = `#### 🔍 What-if Analysis
            
            **Expected changes:** ${{ steps.whatif.outputs.changes-count }}
            
            <details><summary>View details</summary>
            
            \`\`\`json
            ${{ steps.whatif.outputs.whatif-output }}
            \`\`\`
            
            </details>`;
            
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })

  deploy-dev:
    name: Deploy to Dev
    needs: validate
    runs-on: ubuntu-latest
    environment: dev
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      
      - name: Azure Login (OIDC)
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      
      - name: Deploy to Dev
        run: |
          az deployment group create \
              --resource-group dev-rg \
              --template-file infrastructure/main.bicep \
              --parameters infrastructure/params.dev.json \
              --verbose

  deploy-prod:
    name: Deploy to Production
    needs: deploy-dev
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval
    steps:
      - uses: actions/checkout@v4
      
      - name: Azure Login (OIDC)
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      
      - name: Deploy to Production
        run: |
          az deployment group create \
              --resource-group prod-rg \
              --template-file infrastructure/main.bicep \
              --parameters infrastructure/params.prod.json \
              --verbose

Complete Code Examples

Complete Multi-tier Infrastructure in Bicep

// infrastructure/main.bicep
// Complete infrastructure: VNet + Subnets + NSG + App Service + SQL + Key Vault

targetScope = 'resourceGroup'

@description('Target environment')
@allowed(['dev', 'staging', 'prod'])
param environment string

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

@description('SQL administrator login')
param sqlAdminLogin string

@secure()
@description('SQL administrator password')
param sqlAdminPassword string

// Variables
var prefix = '${environment}-${uniqueString(resourceGroup().id)}'
var vnetName = '${prefix}-vnet'
var nsgName = '${prefix}-nsg'
var appSubnetName = 'app-subnet'
var dbSubnetName = 'db-subnet'

var tags = {
  Environment: environment
  ManagedBy: 'Bicep'
  DeployedAt: utcNow()
}

// ===== NETWORK =====

// NSG for the app subnet
resource nsg 'Microsoft.Network/networkSecurityGroups@2023-05-01' = {
  name: nsgName
  location: location
  tags: tags
  properties: {
    securityRules: [
      {
        name: 'AllowHTTPS'
        properties: {
          priority: 100
          protocol: 'Tcp'
          access: 'Allow'
          direction: 'Inbound'
          sourceAddressPrefix: 'Internet'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '443'
        }
      }
      {
        name: 'DenyAllInbound'
        properties: {
          priority: 4096
          protocol: '*'
          access: 'Deny'
          direction: 'Inbound'
          sourceAddressPrefix: '*'
          sourcePortRange: '*'
          destinationAddressPrefix: '*'
          destinationPortRange: '*'
        }
      }
    ]
  }
}

// VNet with 2 subnets
resource vnet 'Microsoft.Network/virtualNetworks@2023-05-01' = {
  name: vnetName
  location: location
  tags: tags
  properties: {
    addressSpace: {
      addressPrefixes: ['10.0.0.0/16']
    }
    subnets: [
      {
        name: appSubnetName
        properties: {
          addressPrefix: '10.0.1.0/24'
          networkSecurityGroup: {
            id: nsg.id
          }
          delegations: [
            {
              name: 'appServiceDelegation'
              properties: {
                serviceName: 'Microsoft.Web/serverFarms'
              }
            }
          ]
        }
      }
      {
        name: dbSubnetName
        properties: {
          addressPrefix: '10.0.2.0/24'
          privateEndpointNetworkPolicies: 'Disabled'
        }
      }
    ]
  }
}

// ===== SQL DATABASE =====

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

resource sqlDatabase 'Microsoft.Sql/servers/databases@2023-05-01-preview' = {
  parent: sqlServer
  name: '${environment}-db'
  location: location
  tags: tags
  sku: {
    name: environment == 'prod' ? 'GP_S_Gen5_2' : 'Basic'
    tier: environment == 'prod' ? 'GeneralPurpose' : 'Basic'
  }
  properties: {
    collation: 'SQL_Latin1_General_CP1_CI_AS'
    maxSizeBytes: environment == 'prod' ? 107374182400 : 2147483648
  }
}

// ===== APP SERVICE =====

resource appPlan 'Microsoft.Web/serverfarms@2023-01-01' = {
  name: '${prefix}-plan'
  location: location
  tags: tags
  sku: {
    name: environment == 'prod' ? 'P1v3' : 'B1'
  }
  kind: 'linux'
  properties: {
    reserved: true
  }
}

resource webApp 'Microsoft.Web/sites@2023-01-01' = {
  name: '${prefix}-app'
  location: location
  tags: tags
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    serverFarmId: appPlan.id
    virtualNetworkSubnetId: '${vnet.id}/subnets/${appSubnetName}'
    httpsOnly: true
    siteConfig: {
      linuxFxVersion: 'NODE|20-lts'
      alwaysOn: environment == 'prod'
      vnetRouteAllEnabled: true
      appSettings: [
        {
          name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
          value: appInsights.properties.ConnectionString
        }
        {
          name: 'ApplicationInsightsAgent_EXTENSION_VERSION'
          value: '~3'
        }
      ]
    }
  }
}

// ===== APPLICATION INSIGHTS =====

resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
  name: '${prefix}-logs'
  location: location
  tags: tags
  properties: {
    sku: {
      name: 'PerGB2018'
    }
    retentionInDays: environment == 'prod' ? 90 : 30
  }
}

resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: '${prefix}-ai'
  location: location
  tags: tags
  kind: 'web'
  properties: {
    Application_Type: 'web'
    WorkspaceResourceId: logAnalytics.id
  }
}

// ===== OUTPUTS =====

output webAppUrl string = 'https://${webApp.properties.defaultHostName}'
output webAppPrincipalId string = webApp.identity.principalId
output sqlServerFqdn string = sqlServer.properties.fullyQualifiedDomainName
output vnetId string = vnet.id

Comparison Tables

ARM/Bicep Deployment Scopes

ScopeCLIPowerShellTemplate $schemaUse case
Resource Groupaz deployment group createNew-AzResourceGroupDeployment...deploymentTemplate.json#Most resources
Subscriptionaz deployment sub createNew-AzSubscriptionDeployment...subscriptionDeploymentTemplate.json#Policies, RBAC, Resource Groups
Management Groupaz deployment mg createNew-AzManagementGroupDeployment...managementGroupDeploymentTemplate.json#Cross-subscription policies
Tenantaz deployment tenant createNew-AzTenantDeployment...tenantDeploymentTemplate.json#AAD, Top-level MGs

Deployment Modes

ModeDescriptionBehaviorRisk
Incremental (default)Adds/updates resources in the templateDoes not delete resources not present⭐ Low
CompleteDeletes resources in the RG not present in the templateMay delete unexpected resources⚠️ High
# Complete mode (caution: may delete resources!)
az deployment group create \
    --resource-group app-rg \
    --template-file main.bicep \
    --mode Complete

Glossary

TermDefinition
ARMAzure Resource Manager — Azure resource orchestrator, receives templates and deploys them
ARM TemplateJSON file describing Azure resources to deploy
BicepMicrosoft DSL for writing readable IaC templates, transpiled to ARM JSON
Resource ProviderAzure service managing a type of resources (e.g. Microsoft.Storage)
Resource GroupLogical container for grouping and managing Azure resources
DeploymentInstance of an ARM deployment in a scope (RG, Sub, MG, Tenant)
ParametersDynamic values passed to the template at deployment time
VariablesCalculated internal template values, not modifiable from outside
OutputsValues returned by the template after deployment (URLs, IDs, etc.)
User-Defined Functions (UDF)Custom functions in an ARM namespace
Bicep ModuleSeparate Bicep file referenced from a main template
Azure Verified Modules (AVM)Official Microsoft modules for Bicep and Terraform
What-ifDeployment dry-run mode — previews changes without applying them
Configuration DriftGap between the state declared in templates and the actual resource state
ARM-TTKARM Template Test Toolkit — PowerShell tool for validating templates
Change AnalysisAzure service for visualizing the history of resource modifications
Symbolic NameInternal identifier of a resource in a Bicep file (≠ Azure name)
targetScopeDeclares the deployment scope of a Bicep template (resourceGroup by default)
IntelliSenseAutocomplete and validation in VS Code with the Bicep extension
Incremental ModeARM deployment mode that does not delete resources not present
Complete ModeARM deployment mode that deletes resources not present in the template
ResourceId()ARM function for building an Azure resource ID
uniqueString()ARM function generating a deterministic unique hash based on an input
utcNow()ARM function returning the current UTC date/time
dependsOnExplicit dependency declaration between ARM resources

Module 1 – Authoring Infrastructure with ARM and Bicep

ARM Template Structure

Main Components

ComponentDescription
$schemaDefines JSON format and version. Template foundation → structure and syntax rules.
parametersValues entered at runtime (region, environment name). Make the template reusable.
variablesCalculated values based on parameters/functions. Reduce repetition.
functionsCustom functions (User-Defined Functions). Reusable logic within the template.
resourcesAzure resources to deploy.
outputsValues returned after deployment (e.g. URL, resource ID).

ARM Functions

  • Built-in functions: toLower, concat, add, first, resourceId, split, uniqueString, utcnow.
  • Usable everywhere in the template (not only in variables).

User-Defined Functions (UDFs)

{
    "functions": [{
        "namespace": "contoso",
        "members": {
            "uniqueName": {
                "parameters": [{"name": "namePrefix", "type": "string"}],
                "output": {
                    "type": "string",
                    "value": "[toLower(concat(parameters('namePrefix'), uniqueString(resourceGroup().id)))]"
                }
            }
        }
    }]
}
  • Rules:
    • Require a dedicated namespace.
    • Cannot access other UDFs or template variables.
    • Parameters must be explicitly defined.

Bicep Templates

Why Bicep?

  • ARM JSON = verbose, difficult to read (optimized for machines).
  • Bicep = concise and readable language, designed for humans.
  • Bicep is transpiled to ARM JSON before being sent to Azure Resource Manager.
  • Benefits: simpler syntax, no excessive quotes, IntelliSense in VS Code.

ARM vs Bicep Workflow

ARM:    JSON template → Azure Resource Manager → Resource Provider → Resource created
Bicep:  .bicep file → transpiled to ARM JSON → Azure Resource Manager → Resource created

Bicep Parameters

// Parameter with a value calculated based on another parameter
param environment string = 'dev'
param storageAccountName string = '${environment}storage${uniqueString(resourceGroup().id)}'
  • Max 256 parameters per template.
  • Unique names (no collision between parameters, variables, resources).
  • Possibility of custom data types for complex scenarios.

Bicep Resource (example: Storage Account)

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
    name: storageAccountName
    location: location
    sku: {
        name: skuName
    }
    kind: 'StorageV2'
    properties: {
        accessTier: 'Hot'
        supportsHttpsTrafficOnly: true
    }
}
  • Type format: ResourceProvider/ResourceType@APIVersion
  • Symbolic name: internal identifier in the Bicep file (≠ Azure name).
  • Bicep templates written in YAML-like syntax, not JSON.

Demo: Deploying Bicep Resources

Deploy via VS Code

  1. Install the Bicep extension for VS Code → IntelliSense, validation.
  2. Create the .bicep file.
  3. Deploy via Azure CLI or PowerShell.

Bicep Variables and Tags

param location string = resourceGroup().location
param skuName string = 'Standard_LRS'

var uniqueStorageName = uniqueString(resourceGroup().id)
var tags = union(resourceGroup().tags, {
    'DeployedAt': utcNow()
    'ManagedBy': 'Bicep'
})

Bicep Modules

Concept

  • Factor reusable Bicep code into separate modules.
  • A module = a .bicep file with its own parameters and outputs.
  • Reference a module from a main template.

Azure Verified Modules (AVM)

  • Official Bicep (and Terraform) modules engineered and supported by Microsoft.
  • Accelerate IaC adoption without developing custom modules.
  • GitHub repository: Azure/bicep-registry-modules.
  • Each module = README with examples (basic → complex).

Using an AVM

// Reference a public module (alias br/public = Bicep Public Module Registry)
module storageAccount 'br/public:storage/storage-account:0.4.1' = {
    name: 'storageAccountDeployment'
    params: {
        name: storageAccountName
        location: location
    }
}

Module 2 – Managing Azure Deployments

Azure CLI

Installation

  • Windows: WinGet, Chocolatey, MSI installer.
  • Linux: apt, yum, official installation script.
  • macOS: Homebrew.
  • Browser: Azure Cloud Shell (always up to date).
  • Docker: docker run mcr.microsoft.com/azure-cli.

Resource Group Commands

# Create a resource group
az group create --name MyResourceGroup --location eastus

# List resource groups (table format)
az group list --output table

# Deploy an ARM/Bicep template
az deployment group create \
    --resource-group MyResourceGroup \
    --template-file main.bicep \
    --parameters @parameters.json

# What-if (preview changes)
az deployment group what-if \
    --resource-group MyResourceGroup \
    --template-file main.bicep

Azure PowerShell

Resource Group Commands

# Create a resource group
New-AzResourceGroup -Name MyResourceGroup -Location eastus

# List resource groups
Get-AzResourceGroup | Format-Table

# Deploy a template
New-AzResourceGroupDeployment `
    -ResourceGroupName MyResourceGroup `
    -TemplateFile main.bicep `
    -TemplateParameterFile parameters.json

# What-if
New-AzResourceGroupDeployment `
    -ResourceGroupName MyResourceGroup `
    -TemplateFile main.bicep `
    -WhatIf

Module 3 – What-if Deployments and Change Tracking

What-if Deployments

Principle

  • Dry run: simulate a deployment without making real changes.
  • Preview exactly what will be created, modified, or deleted.
  • Share proposed changes with stakeholders before deployment.
  • Detect unintended consequences (e.g. accidental deletions, security deactivation).

Symbols in the what-if output

SymbolMeaning
+Resource/property created
~Resource/property modified
-Resource/property deleted
=No change

PowerShell what-if options

New-AzResourceGroupDeployment `
    -ResourceGroupName test-rg `
    -TemplateFile main.bicep `
    -WhatIf `
    -WhatIfExcludeChangeType NoChange, Ignore `
    -WhatIfResultFormat ResourceIdOnly
  • WhatIfExcludeChangeType: filter certain change types in the output.
  • WhatIfResultFormat ResourceIdOnly: display only IDs of impacted resources.

Azure Change Analysis

  • Azure service for viewing the change history of resources.
  • Accessible from the Azure portal → “Change Analysis” blade.
  • Use filters (subscription, resource group, period) to target changes.
  • Detailed view of modified ARM properties.

Module 4 – ARM Template Toolkit (ARM-TTK) and Drift Detection

ARM-TTK

What is it?

  • PowerShell module for validating ARM/Bicep templates against Microsoft best practices.
  • Works on Windows, Linux, macOS (PowerShell Core).
  • Can be integrated into CI/CD pipelines.

Installation

# Download from GitHub (not available on PowerShell Gallery)
# Import the module
Import-Module /path/to/arm-ttk/arm-ttk.psd1

# Test a template
Test-AzTemplate -TemplatePath ./main.json

Rules validated by ARM-TTK

  1. Used parameters and variables: everything declared must be used.
  2. Recent API versions: do not use hardcoded outdated API versions.
  3. No concat for IDs: do not use concat to build already-available Resource IDs.
  4. Location as parameter: location must be a parameter referenced by all resources.

Drift Detection

Method 1: What-if on an existing template

  • Issue: some properties are only set after deployment → false positives.
  • Limitations for drift detection.

Method 2: Export + Comparison

  1. Export the template of the current resource state from the Azure portal.
    • Resource Group → “Export template”.
  2. Compare the exported template with the source template.
  3. Identify differences = drift detected.
# Export from portal or CLI
az group export --name MyResourceGroup > current-state.json

# Compare with git diff or a diff tool

Key Points for Exam / Practice

ARM vs Bicep

  • Bicep = recommended for new Azure-only projects.
  • ARM JSON = still valid, still used in many environments.
  • Bicep is transpiled to ARM JSON → same end result for Azure Resource Manager.
  • An invalid Bicep template = a transpilation error BEFORE reaching Azure.

What-if

  • Always run a what-if before deploying to production.
  • Share results with stakeholders.
  • Unexpected changes = do not deploy.

ARM-TTK

  • Use in CI/CD pipelines for automatic template validation.
  • Most important rules: location parameter referenced everywhere, recent API versions.

AVM (Azure Verified Modules)

  • Prefer AVM over custom modules for standard resources.
  • Documentation = README in the GitHub repo for each module.
  • Access via the br/public: alias in Bicep templates.

General Best Practices

  • Do not hardcode values → use parameters.
  • Tags: inherit tags from the resource group + add your own.
  • allowed values on parameters: fail fast with a clear message (better than an Azure API fail).
  • Variables for logic and calculated values, parameters for user inputs.

Search Terms

azure · infrastructure · arm · bicep · devops · iac · microsoft · what-if · deployments · template · arm-ttk · powershell · change · cli · detection · drift · avm · comparison · functions · resource · templates · account · analysis · commands

Interested in this course?

Contact us to book it or get a custom training plan for your team.