You will deploy Azure resources from a parameterized ARM template and preview changes with what-if before applying. By the end you will run repeatable, idempotent deployments instead of one-off portal clicks.

An ARM template is a JSON document that describes what your environment should look like, not how to build it. You hand the template to Azure Resource Manager (ARM), and ARM works out the order of operations, the API calls, and the retries. Because the template describes desired state, you can run the same deployment ten times and get the same result every time, that property is called idempotency, and it is the foundation of every infrastructure-as-code workflow in Azure.

In this lab you will author a template for a storage account, externalize the environment-specific values into a parameters file, validate and preview the deployment, apply it, and then prove that re-running it changes nothing. These are the exact motions you will repeat later with Bicep, CI/CD pipelines, and larger multi-resource topologies.

Learning Objectives

  • Author an ARM template with parameters.
  • Preview changes with the what-if operation.
  • Deploy idempotently and confirm no drift on re-run.
  • Separate environment-specific values into a parameters file.
  • Validate a template before any resource is touched.
  • Time: ~3 hours · Difficulty: Intermediate · Prereqs: the Azure CLI and a resource group.

Architecture Overview

Everything you deploy in Azure flows through one front door: the Azure Resource Manager. Your local files, template.json plus a parameters file, are sent to ARM by the Azure CLI. ARM first answers the what-if operation with a change set (create, modify, delete, or no change) computed against the live state of the resource group, and only when you approve does it issue the actual create/update calls to the resource providers.

Two things are worth internalizing from this diagram. First, the template is the source of truth: if someone later changes a setting in the portal, the next template deployment is what brings the resource back in line. Second, what-if and create are two views of the same evaluation, the preview you approve is the same plan ARM will execute, which is why previewing before every production deployment is a non-negotiable habit.

Environment Setup

Prerequisites

  • Any OS with a shell: Windows (PowerShell), macOS, or Linux.
  • Azure CLI 2.5x or newer (az version) and an Azure subscription with Contributor access on a resource group.
  • Assumed skills: JSON syntax, basic az commands (see the Azure CLI Setup lab), and what a resource group is.

You will need: the Azure CLI and a target resource group.

Before you begin: always run what-if before deploying to production scopes.

Start by confirming the CLI is installed and reasonably current. ARM template support and the what-if operation have been in the CLI for years, but older builds report what-if results in a noisier format.

CHECK THE CLI VERSION

az version
$ az version
{ "azure-cli": "2.61.0", "azure-cli-core": "2.61.0", "azure-cli-telemetry": "1.1.0", "extensions": {} }

Next, sign in and make sure you are pointed at the right subscription. A template deploys into whichever subscription is currently active, so this check prevents the classic mistake of provisioning into a pay-as-you-go subscription when you meant to use a sandbox.

SIGN IN AND SELECT THE SUBSCRIPTION

az login
az account list --output table
az account set --subscription "My Sandbox Sub"
az account show --query "{name:name, id:id, state:state}" --output table

Finally, create the resource group the template will deploy into. Resource-group-scoped deployments (the az deployment group family) require the group to exist beforehand, the template itself can only create resources inside the group, not the group.

CREATE THE TARGET RESOURCE GROUP

az group create --name tyf-rg --location canadacentral
$ az group create --name tyf-rg --location canadacentral
{ "id": "/subscriptions/11111111-2222-3333-4444-555555555555/resourceGroups/tyf-rg", "location": "canadacentral", "name": "tyf-rg", "properties": { "provisioningState": "Succeeded" } }
Common mistake: running the deployment before creating the resource group fails with ResourceGroupNotFound. If you see that error, you skipped the step above, deployments at the group scope never create their own group.

Step-by-Step Execution

01
Write a parameterized template

Every ARM template has the same skeleton: a $schema pointer, a contentVersion for your own change tracking, a parameters block for inputs, a variables block for values derived inside the template, a resources array describing what to build, and an optional outputs block for values you want back after the deployment. The excerpt below shows the core pattern, a single storage account whose name is supplied at deploy time:

# template.json (excerpt)

"parameters": { "saName": { "type": "string" } },
"resources": [{
  "type": "Microsoft.Storage/storageAccounts",
  "apiVersion": "2023-01-01",
  "name": "[parameters('saName')]",
  "location": "[resourceGroup().location]",
  "sku": { "name": "Standard_LRS" },
  "kind": "StorageV2"
}]

Expected output:

$ az deployment group validate --resource-group tyf-rg --template-file template.json --parameters parameters.dev.json && echo VALID
VALID

Notice the bracket syntax: anything wrapped in [...] is a template expression evaluated by ARM, not a literal string. [parameters('saName')] reads the input, and [resourceGroup().location] makes the resource inherit the location of whatever group it lands in, one less value to hardcode, and one less way for environments to drift apart.

Here is the complete template used for the rest of this lab. It adds input validation (storage account names must be 3-24 characters, lowercase letters and numbers only), a variable for tags, and an output so the deployment hands you back the resource ID:

FULL TEMPLATE, template.json

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "saName": {
      "type": "string",
      "minLength": 3,
      "maxLength": 24,
      "metadata": {
        "description": "Globally unique storage account name, lowercase letters and numbers only."
      }
    },
    "environment": {
      "type": "string",
      "defaultValue": "dev",
      "allowedValues": [ "dev", "test", "prod" ]
    }
  },
  "variables": {
    "commonTags": {
      "deployedBy": "arm-template",
      "environment": "[parameters('environment')]"
    }
  },
  "resources": [
    {
      "type": "Microsoft.Storage/storageAccounts",
      "apiVersion": "2023-01-01",
      "name": "[parameters('saName')]",
      "location": "[resourceGroup().location]",
      "tags": "[variables('commonTags')]",
      "sku": { "name": "Standard_LRS" },
      "kind": "StorageV2",
      "properties": {
        "allowBlobPublicAccess": false,
        "minimumTlsVersion": "TLS1_2",
        "supportsHttpsTrafficOnly": true
      }
    }
  ],
  "outputs": {
    "storageAccountId": {
      "type": "string",
      "value": "[resourceId('Microsoft.Storage/storageAccounts', parameters('saName'))]"
    }
  }
}

Environment-specific values belong in a separate parameters file, not in the template. That way the same template.json deploys to dev, test, and prod with only the parameter file changing, which is exactly how pipelines promote infrastructure between environments.

PARAMETER VALUES, parameters.dev.json

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "saName": { "value": "tyfstore01" },
    "environment": { "value": "dev" }
  }
}
Common mistake: editing the template to swap a value per environment instead of using a parameters file. The moment you have two hand-edited copies of a template, they will drift. One template, many parameter files. Verify your JSON parses at all with jq . template.json, a stray comma is the most common reason a template fails before it even reaches Azure.

What this teaches: Parameters separate inputs from structure, so one template serves every environment instead of one template per environment.

02
Preview with what-if

what-if shows exactly what will change before anything is created or modified. Before previewing, though, run a validation pass: az deployment group validate asks ARM to parse the template, resolve expressions, and check parameter values against their constraints, all without touching the resource group. It catches schema errors and bad input far earlier than a failed deployment does.

VALIDATE THE TEMPLATE (no changes made)

az deployment group validate \
  --resource-group tyf-rg \
  --template-file template.json \
  --parameters parameters.dev.json

Expected output:

Resource and scope                       Change
storageAccounts/tyfsademo001             Create

1 resource would change.
$ az deployment group validate --resource-group tyf-rg --template-file template.json --parameters parameters.dev.json
{ "error": null, "id": "/subscriptions/1111.../resourceGroups/tyf-rg/providers/Microsoft.Resources/deployments/template", "properties": { "provisioningState": "Succeeded" } }

Now the preview. what-if compares your template against the live state of the resource group and reports a symbol per resource: + create, ~ modify, - delete, = no change. Read every line of this output as if you were reviewing a diff in a pull request, because that is exactly what it is.

az deployment group what-if -g tyf-rg --template-file template.json --parameters saName=tyfstore01

Note that the CLI accepts parameter values inline (saName=tyfstore01) as above, or from a file with --parameters parameters.dev.json as in the validate call. For anything beyond a quick experiment, prefer the file, it is reviewable and committed to source control.

$ az deployment group what-if -g tyf-rg --template-file template.json --parameters saName=tyfstore01
Resource and property changes are indicated with these symbols: + Create The deployment will update the following scope: Scope: /subscriptions/1111.../resourceGroups/tyf-rg + Microsoft.Storage/storageAccounts/tyfstore01 apiVersion: "2023-01-01" kind: "StorageV2" location: "canadacentral" sku.name: "Standard_LRS" properties.minimumTlsVersion: "TLS1_2" Resource changes: 1 to create.
How to verify: the summary line is your gate. Before a first deployment you expect Resource changes: 1 to create. If you see to modify or to delete against resources you did not intend to touch, stop and figure out why before proceeding, what-if only reports what is in the template versus what is live, so an unexpected change always means a real difference between the two.

What this teaches: The what-if diff is the safety net: reading a preview before deploying is how you catch destructive changes while they are still cheap.

03
Deploy

The create command is the same shape as what-if, minus the preview. Giving the deployment an explicit --name is optional (the CLI derives one from the template file name), but a meaningful name pays off later when you are paging through deployment history in the resource group. ARM runs in Incremental mode by default: it creates and updates what the template describes and leaves everything else in the group alone. The alternative, Complete mode, deletes anything in the group that is not in the template, powerful, and dangerous enough that you should only use it deliberately.

$ az deployment group create -g tyf-rg --template-file template.json --parameters saName=tyfstore01
"provisioningState": "Succeeded"

The full response is a large JSON document describing the finished deployment. Two fields matter most: properties.provisioningState tells you the outcome, and properties.outputs contains whatever your template's outputs block computed, here, the resource ID of the new storage account.

$ az deployment group create -g tyf-rg -n storage-deploy-01 --template-file template.json --parameters parameters.dev.json --query "{state:properties.provisioningState, saId:properties.outputs.storageAccountId.value}" -o table
State SaId --------- ------------------------------------------------------------------------- Succeeded /subscriptions/1111.../resourceGroups/tyf-rg/providers/Microsoft.Storage/storageAccounts/tyfstore01
Common mistake: treating a red CLI error as "nothing happened." ARM deployments are not atomic across resources; on multi-resource templates, some resources may exist even though the deployment failed. After any failure, check az deployment group show -g tyf-rg -n <name> and the resource group itself before blindly re-running.

Expected output:

{
  "name": "template-001",
  "properties": {
    "provisioningState": "Succeeded",
    "outputs": {
      "storageAccountId": {
        "value": "/subscriptions/.../resourceGroups/tyf-rg/providers/Microsoft.Storage/storageAccounts/tyfsademo001"
      }
    }
  }
}

What this teaches: Deployments are asynchronous ARM operations; the CLI returns once the operation is accepted, and provisioningState tells you when it truly finished.

04
Inspect the deployed resource

Never trust a deployment you have not inspected. List what is actually in the resource group, then query the specific resource for the properties the template was supposed to set. This is where you confirm the security-relevant settings, TLS floor, public access disabled, really applied and were not silently ignored.

LIST RESOURCES AND CHECK KEY PROPERTIES

az resource list --resource-group tyf-rg --output table

az storage account show \
  --name tyfstore01 \
  --resource-group tyf-rg \
  --query "{name:name, location:location, sku:sku.name, tls:minimumTlsVersion, publicAccess:allowBlobPublicAccess, tags:tags}"

Expected output:

$ az storage account show -n tyfsademo001 -g tyf-rg --query "{name:name, state:provisioningState, location:primaryLocation}" -o table
Name           State      Location
-------------  ---------  ----------
tyfsademo001   Succeeded  eastus
$ az storage account show --name tyfstore01 --resource-group tyf-rg --query "{name:name, location:location, sku:sku.name, tls:minimumTlsVersion, publicAccess:allowBlobPublicAccess}"
{ "location": "canadacentral", "name": "tyfstore01", "publicAccess": false, "sku": "Standard_LRS", "tls": "TLS1_2" }
How to verify: every property in the query output should match the template. If tls came back as null or an older value, the property was not honored for that apiVersion, check the ARM template reference for Microsoft.Storage/storageAccounts and confirm the property name and apiVersion line up.

What this teaches: Every deployment writes to the activity log, which is where you audit who changed what and when.

05
Prove idempotency by re-running

This is the step that separates declarative infrastructure from scripting. Run the exact same deployment command a second time. ARM compares the template to the live state, finds nothing to change, and returns success without modifying the resource. The deployment succeeds; the work performed is zero.

RE-RUN THE SAME DEPLOYMENT

az deployment group create \
  --resource-group tyf-rg \
  --name storage-deploy-02 \
  --template-file template.json \
  --parameters parameters.dev.json \
  --query properties.provisioningState

Expected output:

$ az deployment group what-if --resource-group tyf-rg --template-file template.json --parameters parameters.dev.json
0 resources would change. The deployment is idempotent.
$ az deployment group create --resource-group tyf-rg --name storage-deploy-02 --template-file template.json --parameters parameters.dev.json --query properties.provisioningState
"Succeeded"

Idempotency is what makes templates safe to put in a pipeline: a scheduled or triggered re-run converges the environment toward the template rather than duplicating resources or erroring on things that already exist. The formal proof comes in the next section, where what-if reports zero changes.

Common mistake: re-running with a different inline parameter value (for example a new saName) and being surprised when a second storage account appears. Incremental mode does not delete resources that fall out of the template, it just stops managing them. The old account remains until you remove it explicitly.

What this teaches: Idempotency is the core promise of declarative IaC: re-running a template should converge, not duplicate.

Step Flow Map

Each box is one step in the lab; work them in order, since later steps build on earlier state.

Progress So Far

The three core milestones, authoring the template, previewing with what-if, and the initial deployment, are complete. Steps 04-06 then closed the loop: you inspected the live resource, proved the deployment is idempotent, and cleaned up.

How It Works

An ARM template is a JSON document that declares the desired end state of a set of Azure resources. You submit it to the Azure Resource Manager control plane, and ARM computes the operations needed to converge the current state toward the declared state. This is the declarative model: you describe what should exist, not the sequence of API calls to create it, so the same template produces the same environment every time it runs.

Anything wrapped in square brackets, like [parameters('saName')], is a template expression evaluated server-side at deployment time. Parameters keep environment-specific values out of the template body, variables derive values inside it, and outputs hand values back to you after deployment. The what-if operation runs the same evaluation as a real deployment but stops before making changes, producing a diff of creates, modifies, and deletes. Reading that diff is the single most important habit in this lab.

Deployments are incremental by default: re-running a template only changes what drifted, which makes deployments idempotent. The dangerous twin is Complete mode, which deletes any resource in the group that is not declared in the template. Complete mode has legitimate uses for strict environments, but it should never be the default you reach for in a shared resource group.

Testing & Validation

az deployment group what-if -g tyf-rg --template-file template.json --parameters saName=tyfstore01

Re-running what-if after a successful deploy should report no changes, proving the template is idempotent.

$ az deployment group what-if -g tyf-rg --template-file template.json --parameters saName=tyfstore01
Resource and property changes are indicated with these symbols: = No change Scope: /subscriptions/1111.../resourceGroups/tyf-rg = Microsoft.Storage/storageAccounts/tyfstore01 Resource changes: no change.

Beyond the what-if check, three quick queries give you a complete validation picture. First, confirm the deployment record itself shows success in the group's deployment history. Second, confirm the resource reports healthy from its own provider's point of view. Third, deliberately introduce drift, flip a tag in the portal, then run what-if again and watch it report a ~ modify. That last experiment is the one that makes the "template is the source of truth" idea concrete.

VALIDATION QUERIES

# Deployment history for the resource group:
az deployment group list --resource-group tyf-rg \
  --query "[].{name:name, state:properties.provisioningState, time:properties.timestamp}" -o table

# Live resource health:
az storage account show --name tyfstore01 --resource-group tyf-rg \
  --query "{state:provisioningState, httpsOnly:enableHttpsTrafficOnly}"

# Simulate drift, then see what-if catch it:
az resource tag --ids $(az storage account show -n tyfstore01 -g tyf-rg --query id -o tsv) \
  --set tags.environment=production
az deployment group what-if -g tyf-rg --template-file template.json --parameters parameters.dev.json
How to verify: after the drift experiment, what-if should show ~ Microsoft.Storage/storageAccounts/tyfstore01 with tags.environment changing back to "dev". Re-deploy the template to actually restore it, and run what-if once more to confirm you are back to no change.

CLEAN UP

When the lab is done, remove what you created so it stops billing. Delete only the storage account: tyf-rg is shared with the other Cloud and Azure labs, so never delete the whole group here.

az storage account delete --name tyfstore01 --resource-group tyf-rg --yes
How to verify: az storage account show -n tyfstore01 -g tyf-rg should fail with ResourceNotFound once deletion completes. The deployment record itself remains in the group history (az deployment group list -g tyf-rg); deleting a deployment never deletes its resources, which is why the account above is removed explicitly.

Troubleshooting

Quick-reference for the failures you are most likely to hit in this lab.

SymptomLikely causeFix
Template validation fails with InvalidTemplateJSON syntax error or a malformed template expressionRun az deployment group validate and read the first error; most failures are a missing comma or an unclosed bracket.
Storage account name is rejectedStorage names must be 3-24 lowercase letters and numbers, and globally uniquePick a shorter unique name; prefix it with your initials and a random suffix.
what-if shows unexpected deletesDeployment mode is set to CompleteSwitch back to Incremental unless you explicitly intend to remove undeclared resources.
Deployment hangs for many minutesResource provider not registered, or quota exhaustion in the regionCheck az provider show for the provider state and review the activity log for the failing operation.
Parameter file not foundRelative path resolved from the wrong working directoryUse an absolute path, or cd into the template directory before deploying.
Secret value leaks into logsPassed on the command line instead of through a fileMove secrets to a parameters file excluded from version control, or better, a key vault reference.
Troubleshooting
  • Name already taken: storage account names are globally unique; choose another.
  • Validation failed: run az deployment group validate to see the schema error.
  • Unexpected what-if changes: a property drifted in the portal; let the template be the source of truth.
  • AuthorizationFailed on deployment: your account (or the pipeline's service principal) lacks write permission on the resource group. You need at least the Contributor role at that scope. Check with az role assignment list --assignee $(az ad signed-in-user show --query id -o tsv) --resource-group tyf-rg -o table and have an owner grant the role if it is missing.
  • MissingSubscriptionRegistration: the subscription has never used the Storage resource provider, so ARM refuses to deploy Microsoft.Storage resources. Register it once per subscription with az provider register --namespace Microsoft.Storage, then wait a minute and retry. Confirm with az provider show --namespace Microsoft.Storage --query registrationState, you want "Registered".
  • Template deploys but properties are wrong: the property name or the apiVersion does not match. Resource properties are versioned; a property that exists in 2023-01-01 may be named differently (or not exist) in an older apiVersion. Cross-check against the ARM template reference for the resource type, and keep apiVersions current but pinned, never leave them floating.

Extension Ideas

  • Move to the cleaner Bicep syntax in Infrastructure as Code.
  • Deploy from a pipeline with GitHub Actions.
  • Audit deployed resources with CSPM.
  • Add a second resource (a blob container or a Key Vault) and use dependsOn to control creation order.
  • Split the template into linked or nested templates once it grows past a few resources.

Key Results

  • Deployed resources declaratively from a parameterized template.
  • Previewed every change with what-if before applying.
  • Confirmed idempotency: a re-run reported no changes.
  • Replaced manual portal steps with repeatable deployments.

Key Takeaways

  • Templates declare desired state; Azure Resource Manager figures out the how.
  • One template, many parameter files, never hand-edit copies per environment.
  • Validate first, what-if second, deploy third. Read the change summary line like a diff review.
  • Incremental mode (the default) adds and updates; it never cleans up resources that leave the template.
  • Idempotency is the payoff: re-running a deployment is safe, so pipelines can converge environments on every run.
  • Deployment history and what-if are your audit trail, drift is always visible if you look.

Practice Challenges

Challenge 1: Add a second resource

Goal: Extend the template with a blob container inside the storage account and pass the account name between resources without hardcoding it.

Hint: Nest the container under the storage account resource and reference it with its symbolic name, then read it back from the deployment outputs.

Challenge 2: Deploy from a parameters file

Goal: Move every environment-specific value into a params.json file and deploy with --parameters @params.json.

Hint: Keep the parameters file out of git; it is the one artifact that differs per environment.

Challenge 3: Preview a destructive change safely

Goal: In a scratch resource group, run what-if with Complete mode and observe which out-of-band resources it would delete.

Hint: Never run Complete mode against a real group; the scratch group exists so you can see the blast radius safely.