Lesson 13 — Infrastructure as Code: ARM Templates & Bicep

Domain 3 — Compute AZ-104: 20–25% ~35 min Prereq: Lessons 01–12

Why Infrastructure as Code Is Non-Negotiable

Every resource you have deployed through the Azure portal can be deleted, misconfigured, or drift from its intended state. In production environments at any serious scale, manual portal operations are an audit failure, a reliability risk, and a velocity bottleneck. Infrastructure as Code (IaC) solves this by encoding your infrastructure in files that can be version-controlled, peer-reviewed, tested, and deployed repeatably — the same way application code is developed.

Azure's native IaC system is built on the Azure Resource Manager (ARM) API. ARM is the control plane that every Azure operation goes through — the portal, the Azure CLI, PowerShell, and REST APIs all translate their requests into ARM API calls. ARM templates and Bicep are declarative languages that describe what resources you want; ARM figures out how to create or update them.

ARM is already everywhere When you create a resource in the portal, Azure is executing an ARM API call on your behalf. In the portal, every resource has an "Export template" option that shows you the ARM JSON for the deployed resource. This is a useful technique for learning ARM syntax: create something in the portal, export the template, and study the structure.

ARM Template Structure (JSON)

An ARM template is a JSON file with a defined schema. Every valid ARM template must contain $schema, contentVersion, and resources. The other sections are optional but frequently used.

SectionRequiredPurpose
$schema Yes URL to the ARM template schema. Tells tools which schema version to validate against. Different schemas exist for subscription vs. resource group deployments.
contentVersion Yes Your version string for the template (e.g. "1.0.0.0"). Purely informational — ARM does not interpret it.
parameters No User-supplied inputs at deploy time. Each parameter has a type, optional default value, allowed values, and description. Decouple template from environment-specific values.
variables No Computed values derived from parameters or template functions. Reduce repetition — define a naming convention once, reference it everywhere.
resources Yes Array of resource definitions. Each entry specifies type, apiVersion, name, location, and properties.
outputs No Values returned after deployment completes. Use to surface resource IDs, connection strings, or other outputs for downstream pipeline steps.
functions No Custom template functions (user-defined). Rarely used — Bicep is preferred for complex logic.

ARM template key functions

ARM templates have a rich built-in function library. These are the ones you must know for the exam and for daily use:

FunctionReturnsCommon Use
resourceGroup().location String Deploy resources to the same region as the resource group without hardcoding a location
subscription().subscriptionId String Build resource IDs that reference resources in the current subscription
parameters('name') Any Reference a parameter value anywhere in the template
variables('name') Any Reference a computed variable
concat('a', 'b', 'c') String / Array Build resource names from multiple parts
uniqueString(seed) 13-char string Generate a deterministic unique suffix for globally unique names (e.g. storage accounts). Same seed always returns same string.
format('{0}-{1}', a, b) String String formatting — cleaner than nested concat() calls
reference('resourceName') Object Get a runtime property of another resource in the template (e.g. a storage account's primary endpoint). Implicitly creates a dependency.
uniqueString() is deterministic, not random uniqueString(resourceGroup().id) always returns the same 13-character hash for the same resource group. This is by design — idempotent deployments produce the same resource names on repeat runs. It is a hash, not a random value. Two deployments to the same resource group will always generate the same string.

Resource Dependencies

ARM deploys resources in parallel by default for speed. When one resource must be created before another, you declare a dependency. There are two mechanisms:

Explicit dependencies — dependsOn

The dependsOn array lists resources by name or resource ID that must be deployed successfully before this resource is started. Use when you need to force ordering but there is no property reference between resources.

Implicit dependencies — reference()

When you use the reference() function to read a property from another resource, ARM automatically infers a dependency — it knows it must deploy the referenced resource first. Implicit dependencies via reference() are preferred when applicable: they make the dependency self-documenting and are less fragile than string-based dependsOn.

MethodHow It WorksWhen to Use
dependsOn Explicit array of resource names or IDs to wait for before deploying this resource When no property reference exists but ordering is required (e.g. a script that must run after a VM is created)
reference() ARM reads the referenced resource's runtime state — implicitly waits for the resource to exist When you need a property value from another resource (e.g. a storage account's connection string to inject into a web app's config)

Deployment Modes: Incremental vs. Complete

Every ARM deployment has a mode that controls what ARM does with resources in the resource group that are not in the template.

Incremental mode (default)

ARM deploys the resources in the template. Resources in the resource group that are not in the template are left unchanged. This is the safe default — you can deploy a partial template without risk of deleting resources that are managed by other templates or were created manually.

Complete mode

ARM deploys the resources in the template, then deletes any resources in the resource group that are NOT in the template. Complete mode enforces the desired state — the resource group will exactly match what the template describes after deployment.

Complete mode will silently delete resources If you run a Complete mode deployment with a template that is missing resources (because you forgot to include them, or you have resources created outside IaC), those resources will be permanently deleted with no warning prompt in automated pipelines. Always run a what-if preview before any Complete mode deployment. This is the most dangerous ARM operation available to a standard administrator.
ModeResources in templateResources NOT in templateSafe to run partially?
Incremental Created or updated Left unchanged Yes
Complete Created or updated Deleted No — only run with a full template and after what-if

What-If Deployment

The what-if operation previews what changes an ARM deployment would make without executing any of them. It shows each resource that would be created, modified, deleted, or remain unchanged. Think of it as a dry run.

Running what-if from Azure CLI:

az deployment group what-if \
  --resource-group rg-example-prod \
  --template-file main.bicep \
  --parameters @params.prod.json

The output classifies each resource into one of four categories:

  • Create: resource does not exist and will be created
  • Modify: resource exists and one or more properties will change. Shows the before/after property diff.
  • Delete: resource exists but is not in the template — will be deleted (Complete mode only)
  • No change: resource exists and template matches current state exactly
  • Ignore: resource exists but ARM will not manage it (e.g. extension resources)
What-if is not always 100% accurate What-if relies on Azure resource providers returning accurate current state and change predictions. Some resource providers have known inaccuracies (they may show "Modify" for a property that won't actually change, or miss a property change). Treat what-if output as a strong signal, not a guarantee. Always review it critically before a Complete mode deployment.

Bicep: The Preferred IaC Authoring Language for Azure

Bicep is Microsoft's domain-specific language for Azure infrastructure. It compiles directly to ARM JSON — it is not a wrapper or an abstraction over ARM, it IS ARM, with a much cleaner syntax. Bicep is the recommended authoring experience; ARM JSON is the wire format that gets sent to the ARM API.

Why Bicep instead of ARM JSON?

  • No $schema declarations, no quotation marks around identifiers, no explicit dependsOn when using symbolic references.
  • Native type system with IntelliSense in VS Code (Bicep extension).
  • String interpolation: '${prefix}-${uniqueString(resourceGroup().id)}' instead of concat(parameters('prefix'), '-', uniqueString(resourceGroup().id)).
  • Modules: reusable Bicep files with inputs and outputs. Compose large deployments from smaller, tested units.
  • Loops and conditional deployment with clean syntax.
  • Compile to ARM JSON for sharing with teams not using Bicep.

Bicep keywords

KeywordPurpose
paramDeclare a parameter (user input). Supports types, defaults, decorators like @description and @allowed.
varDeclare a computed variable (not user input).
resourceDeclare an Azure resource with a symbolic name, type, and API version.
moduleReference a reusable Bicep file, passing parameters and receiving outputs.
outputReturn a value from the deployment (or module) to the caller.

Symbolic references replace reference()

In ARM JSON, accessing a property of another resource requires the verbose reference('resourceName').properties.xxx function call. In Bicep, you use the resource's symbolic name directly: storageAccount.properties.primaryEndpoints.blob. Bicep infers the dependency automatically — no dependsOn required.

Conditional deployment

Deploy a resource only if a condition is true:

param deployStorage bool = true

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = if (deployStorage) {
  name: 'mystorage${uniqueString(resourceGroup().id)}'
  location: resourceGroup().location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
}

Loops

Deploy multiple resources from a single definition:

param storageNames array = ['logs', 'data', 'archive']

resource storageAccounts 'Microsoft.Storage/storageAccounts@2023-01-01' = [for name in storageNames: {
  name: '${name}${uniqueString(resourceGroup().id)}'
  location: resourceGroup().location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
}]

Side-by-Side: Bicep vs. ARM JSON

Both of the following examples deploy a storage account with a configurable name prefix and SKU. They are functionally identical — the Bicep compiles to ARM JSON that is nearly identical to the handwritten ARM JSON version.

Bicep — minimal storage account module

@description('Prefix for the storage account name')
param namePrefix string = 'contoso'

@description('Storage SKU')
@allowed(['Standard_LRS', 'Standard_GRS', 'Standard_ZRS', 'Premium_LRS'])
param storageSku string = 'Standard_LRS'

var storageAccountName = '${toLower(namePrefix)}${uniqueString(resourceGroup().id)}'

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

output storageAccountId string = storageAccount.id
output blobEndpoint string = storageAccount.properties.primaryEndpoints.blob

ARM JSON — equivalent template

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "namePrefix": {
      "type": "string",
      "defaultValue": "contoso",
      "metadata": { "description": "Prefix for the storage account name" }
    },
    "storageSku": {
      "type": "string",
      "defaultValue": "Standard_LRS",
      "allowedValues": ["Standard_LRS", "Standard_GRS", "Standard_ZRS", "Premium_LRS"],
      "metadata": { "description": "Storage SKU" }
    }
  },
  "variables": {
    "storageAccountName": "[concat(toLower(parameters('namePrefix')), uniqueString(resourceGroup().id))]"
  },
  "resources": [
    {
      "type": "Microsoft.Storage/storageAccounts",
      "apiVersion": "2023-01-01",
      "name": "[variables('storageAccountName')]",
      "location": "[resourceGroup().location]",
      "sku": { "name": "[parameters('storageSku')]" },
      "kind": "StorageV2",
      "properties": {
        "minimumTlsVersion": "TLS1_2",
        "allowBlobPublicAccess": false,
        "supportsHttpsTrafficOnly": true
      }
    }
  ],
  "outputs": {
    "storageAccountId": {
      "type": "string",
      "value": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]"
    },
    "blobEndpoint": {
      "type": "string",
      "value": "[reference(variables('storageAccountName')).primaryEndpoints.blob]"
    }
  }
}
Bicep is less code for the same result The Bicep version is approximately 25 lines; the ARM JSON equivalent is approximately 50 lines. For complex infrastructure with dozens of resources, this difference compounds significantly. Bicep also has compile-time type checking and IDE validation — ARM JSON errors are often caught only at deployment time.

Bicep Modules

A Bicep module is a reusable Bicep file that accepts parameters and returns outputs. Modules are the primary mechanism for composing large deployments from smaller, tested components — a platform team can publish a "blessed" storage module that enforces corporate defaults (TLS 1.2, no public access, soft delete enabled) and application teams consume it without needing to know or replicate those settings.

Calling a module from a parent Bicep file:

module storage './modules/storage.bicep' = {
  name: 'storageDeployment'
  params: {
    namePrefix: 'payments'
    storageSku: 'Standard_GRS'
  }
}

// Consume the module's output
output blobUri string = storage.outputs.blobEndpoint

Modules can also be referenced from a Bicep Registry (a private module registry hosted in ACR) or the public Bicep Registry on GitHub — enabling teams to share vetted modules across the organisation.

Template Specs and Azure Deployment Stacks

Template Specs

Template Specs store versioned ARM templates or compiled Bicep files as first-class Azure resources with their own resource ID. They live in a resource group and support RBAC — you can give a team permission to deploy a Template Spec without giving them access to the source code in your Git repository.

Benefits: versioned history of approved templates, RBAC-controlled access for deployment, no local file needed to deploy (reference the spec by resource ID), deployable from the portal, CLI, or pipelines.

Azure Deployment Stacks

Azure Deployment Stacks are a newer mechanism that groups related resources (potentially across multiple resource groups or subscriptions) into a managed unit with a lifecycle. Key capabilities:

  • Unified lifecycle: create, update, and delete all managed resources together as a stack.
  • Deny settings: the stack can apply a deny assignment to prevent modification of managed resources outside the IaC pipeline — ensuring infrastructure drift is impossible without going through the approved deployment process.
  • Detach or delete on update: when a resource is removed from the stack template, you can configure whether it is deleted or simply detached (unmanaged but not deleted).
  • Deployment Stacks are the strategic replacement for Azure Blueprints, which is deprecated.
Azure Blueprints is deprecated — use Deployment Stacks Azure Blueprints was the previous mechanism for packaging policies, role assignments, and ARM templates for environment bootstrapping. It has been deprecated in favour of a combination of Azure Policy, Azure RBAC, and Deployment Stacks. If you see Blueprint-related content in study materials, confirm whether it is still current exam content.
FeatureTemplate SpecsDeployment Stacks
Primary purpose Store and version ARM/Bicep templates as Azure resources Manage a group of resources as a lifecycle unit
Drift prevention No — just stores the template Yes — deny assignments prevent out-of-band changes
Cross-scope resources Depends on template scope Yes — stack can span resource groups and subscriptions
RBAC for deployment Yes — assign Reader/Contributor to the spec resource Via standard Azure RBAC on the stack resource

Check Your Understanding

Click any option to see immediate feedback. Answers represent correct behaviour in a real Azure environment.

1. You run an ARM deployment in Complete mode against a resource group that contains resources A, B, and C. Your template only defines resources A and B. What happens to resource C?

Complete mode enforces desired state: after deployment, the resource group contains exactly and only what the template defines. Resource C is permanently deleted. This is why Complete mode is dangerous with partial templates, and why running a what-if preview first is mandatory practice. Incremental mode (the default) would leave C untouched.

2. You use uniqueString(resourceGroup().id) as part of a storage account name in your ARM template. A colleague asks whether the storage account will get a different name each time the template is deployed. What is the correct answer?

uniqueString() is a deterministic hash function, not a random generator. Given the same input string (seed), it always returns the same 13-character base-58 string. Using resourceGroup().id ensures idempotent naming — re-running the template against the same resource group always generates the same storage account name, which ARM then finds already exists and either updates or leaves unchanged.

3. A platform engineering team publishes a "blessed" Bicep storage module that enforces organisational security defaults. Application teams need to deploy storage accounts using this module. What is the correct mechanism for sharing and consuming the module?

Bicep modules can be published to and consumed from a Bicep Registry — either a private registry hosted in Azure Container Registry (ACR) or the public Bicep Registry. Teams reference the module with a registry path like br:myregistry.azurecr.io/modules/storage:1.0. This enables version management, controlled updates, and centralised governance without copying files. Template Specs also support this pattern but are for ARM JSON, not Bicep module composition.

4. Before running a Complete mode deployment against a production resource group, what operation should always be performed first?

What-if is the correct and standard practice before Complete mode deployments. It shows precisely which resources would be created, modified, or deleted without actually making any changes. This lets you catch any resources that were accidentally omitted from the template before they are permanently deleted. Resource locks would block the deployment entirely rather than previewing it; soft-delete is not universally available; and manually comparing exported templates is error-prone and does not show the actual deployment impact.

5. You need ARM to deploy resource B only after resource A is fully created, but you don't need any property from resource A. Which approach should you use?

dependsOn creates an explicit ordering dependency. It tells ARM to deploy resource A to completion before starting resource B. Use dependsOn when you need ordering but do not have a property reference — for example, a deployment script that must run after a VM is provisioned. When you do need a property value, prefer reference() which creates an implicit dependency that is self-documenting. There is no after() function in ARM.

6. A platform team wants to publish approved ARM templates for application teams to deploy, with RBAC controlling who can deploy which template — without exposing the source template files in source control. What Azure feature enables this?

Template Specs store versioned ARM or Bicep templates as Azure resources with a resource ID. Access is controlled via Azure RBAC — you can give an application team Template Spec Contributor or Reader + deploy permission on the spec resource without giving them access to the platform team's Git repository. Teams deploy directly from the Template Spec by referencing its resource ID. Azure Blueprints is deprecated. DevOps variable groups are not designed for this purpose. Policy initiatives are governance, not deployment mechanisms.
Primary source for this lesson What is Bicep? — Azure Resource Manager (Microsoft Learn)

Also read the ARM template structure documentation and the what-if deployment guide. The Bicep Playground at aka.ms/bicepdemo lets you write Bicep and see the compiled ARM JSON in real time — essential for building intuition about how the two relate.

Questions for your teacher (the AI agent)
This lesson covered the IaC fundamentals for Azure. Go deeper on these topics:
  • Walk me through a complete Bicep module design for deploying a web application stack (App Service + Storage + Key Vault) with proper outputs and cross-module references.
  • How do Deployment Stacks compare to Terraform when managing Azure infrastructure — what are the trade-offs?
  • What is the difference between resource-group-scoped, subscription-scoped, and management-group-scoped ARM deployments, and when would I use each?
  • How do I handle secrets (database connection strings, API keys) in Bicep deployments without putting them in plain text in template parameters?
Coming up: Lesson 14 — Virtual Networks Now that you can deploy infrastructure as code, Lesson 14 moves into the networking domain — the plumbing that connects everything. Virtual Networks, subnets, Network Security Groups, route tables, and peering are the foundation for every Azure networking architecture. These are among the highest-weight topics in the AZ-104 exam.