You will provision the same Azure resources with both Bicep and Terraform, then detect and remediate drift. By the end you will manage cloud infrastructure as version-controlled code with a clear plan-before-apply workflow.

Infrastructure as Code (IaC) means your environment is described in text files that live in source control, and every change to that environment is a change to those files, reviewed, versioned, and applied by a tool instead of clicked together in the portal. Azure has two first-class ways to do this. Bicep is Microsoft's own declarative language that compiles down to ARM templates and talks to Azure Resource Manager directly. Terraform is HashiCorp's tool, cloud-agnostic, and it keeps a state file recording every resource it manages so it can compute precise change plans later.

The single most important difference between the two is that state file. Bicep asks Azure "what exists right now?" every time it runs, so it needs no bookkeeping of its own. Terraform compares your configuration against its recorded state, which makes its plans fast and exact, but also means the state file is a piece of critical infrastructure that must be stored safely and locked during runs. This lab makes you operate both tools on the same target resources so the trade-offs stop being theoretical.

You will also practice the workflow that makes IaC safe in production: preview before apply. Bicep's what-if and Terraform's plan both answer "what would change if I ran this?" before anything is touched, and both can be used after the fact to detect drift, the gap between what your code says and what actually exists, usually caused by someone editing resources by hand.

Learning Objectives

  • Write a Bicep module and deploy it.
  • Write equivalent Terraform with remote state.
  • Detect configuration drift with plan/what-if.
  • Preview every change before applying it, and remediate drift through code only.
  • Explain how Terraform state differs from Bicep's stateless model and why remote state with locking matters in a team.
  • Time: ~4 hours · Difficulty: Intermediate · Prereqs: Azure CLI, Bicep, Terraform.

Architecture Overview

Both tools follow the same mental loop: you write code, the tool computes a plan against the current reality, you apply the approved plan, and Azure resources come into existence (or are corrected). Terraform's extra moving part is the state file, which feeds into every plan so Terraform knows what it already manages. Drift detection is just running the plan step again later and reading what it reports.

Notice that the drift-check arrow flows from live Azure back into the plan step: a plan is never only a pre-flight check, it is also your ongoing audit. If the plan is empty, code and reality agree. If it is not empty and you did not change the code, someone changed reality, and the plan output tells you exactly what, and how the next apply will put it back.

Environment Setup

Prerequisites

  • Azure CLI with Bicep (az bicep version) and Terraform 1.7+ installed.
  • An Azure subscription with Contributor access, plus a storage account for Terraform remote state.
  • Assumed skills: JSON or HCL comfort, basic az commands, and what a resource group is.

You will need: the Azure CLI (with Bicep) and Terraform 1.7.

Before you begin: store Terraform state remotely (Azure Storage) for team-safe locking.

Start by confirming both toolchains are present and current. The Azure CLI ships Bicep as a built-in component (older installs needed az bicep install separately), and Terraform is a single binary. Check all three versions up front so a toolchain problem cannot masquerade as a code problem halfway through the lab.

CHECK THE TOOLCHAIN VERSIONS

az version
az bicep version
terraform version
$ az bicep version && terraform version
Bicep CLI version 0.26.170 (e9abaf016a) Terraform v1.7.5 on darwin_arm64 + provider registry.terraform.io/hashicorp/azurerm v3.97.1

Next, sign in and point the CLI at the subscription you intend to use. Both Bicep deployments and the Terraform Azure provider pick up these Azure CLI credentials by default, so this one login authenticates the whole lab.

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

Create the lab resource group, plus a separate resource group and storage account to hold Terraform's remote state. Keeping state in its own group is deliberate: it survives deletion of the lab environment, and access to it can be restricted independently, the state file can contain sensitive values and deserves tighter RBAC than the resources it describes.

CREATE THE LAB GROUP AND THE STATE BACKEND STORAGE

# Lab resources live here:
az group create --name tyf-rg --location canadacentral

# Terraform state lives here, in its own group:
az group create --name tyf-tfstate-rg --location canadacentral
az storage account create \
  --name tyftfstate01 \
  --resource-group tyf-tfstate-rg \
  --sku Standard_LRS \
  --kind StorageV2 \
  --allow-blob-public-access false \
  --min-tls-version TLS1_2
az storage container create \
  --name tfstate \
  --account-name tyftfstate01 \
  --auth-mode login
$ 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: container creation failing with an authorization error even though you own the subscription. Blob data-plane access is separate from management rights, with --auth-mode login your account needs the Storage Blob Data Contributor role on the storage account. Grant it with az role assignment create --assignee $(az ad signed-in-user show --query id -o tsv) --role "Storage Blob Data Contributor" --scope $(az storage account show -n tyftfstate01 -g tyf-tfstate-rg --query id -o tsv) and wait a minute for propagation.

Step-by-Step Execution

01
Deploy with Bicep

A Bicep file declares resources in a terse, readable syntax. Parameters (param) are inputs you supply at deploy time; resource blocks describe desired state, each pinned to a resource type and apiVersion. The excerpt below, the same storage account used throughout this lab, shows the whole pattern in seven lines: a name parameter and a storage account that inherits its resource group's location.

// main.bicep
param name string
resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: name
  location: resourceGroup().location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
}

Expected output:

$ az deployment group create -f main.bicep -g tyf-rg --query "properties.provisioningState" -o tsv
Succeeded

The symbolic name sa is only a reference inside the file, it is not the deployed name and never appears in Azure. What lands in Azure is the value of the name parameter. Before deploying, compile and validate: az bicep build surfaces syntax errors with line numbers locally, and az deployment group what-if asks ARM what would change, without changing anything.

COMPILE AND PREVIEW (no changes made)

az bicep build --file main.bicep

az deployment group what-if \
  --resource-group tyf-rg \
  --template-file main.bicep \
  --parameters name=tyfiac01
$ az deployment group what-if -g tyf-rg --template-file main.bicep --parameters name=tyfiac01
Resource and property changes are indicated with these symbols: + Create Scope: /subscriptions/1111.../resourceGroups/tyf-rg + Microsoft.Storage/storageAccounts/tyfiac01 apiVersion: "2023-01-01" kind: "StorageV2" location: "canadacentral" sku.name: "Standard_LRS" Resource changes: 1 to create.

The preview says exactly one resource will be created and nothing else will move, that is the green light to deploy for real. The create command is identical, minus the what-if operation.

az deployment group create -g tyf-rg --template-file main.bicep --parameters name=tyfiac01
$ az deployment group create -g tyf-rg --template-file main.bicep --parameters name=tyfiac01 --query properties.provisioningState
"Succeeded"
Common mistake: running the deployment before creating the resource group fails with ResourceGroupNotFound, group-scoped deployments never create their own group. Verify success with az storage account show -n tyfiac01 -g tyf-rg --query provisioningState rather than trusting the CLI exit alone.

What this teaches: Bicep is ARM JSON with the verbosity removed; it compiles to a template before anything reaches Azure.

02
Configure Terraform remote state

Before writing any Terraform resources, decide where its state will live. The default is a local terraform.tfstate file, fine for a solo experiment, unacceptable for a team: two people running apply at once would race, and the file itself is easy to lose or leak. The azurerm backend stores state as a blob in Azure Storage and, crucially, takes a lease on the blob during every state operation, that lease is the lock that prevents concurrent applies from corrupting each other.

BACKEND CONFIGURATION, backend.tf

terraform {
  required_version = ">= 1.7.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.97"
    }
  }

  backend "azurerm" {
    resource_group_name  = "tyf-tfstate-rg"
    storage_account_name = "tyftfstate01"
    container_name       = "tfstate"
    key                  = "iac-lab.tfstate"
  }
}

provider "azurerm" {
  features {}
}

Expected output:

Initializing the backend...
Successfully configured the backend "azurerm"!
Terraform has been successfully initialized!

Three things to note here. First, the backend block cannot use variables, backend configuration is evaluated before anything else, so these values are literals (or supplied at init time with -backend-config). Second, the key is the blob name; use one key per environment so dev and prod state never mix. Third, features {} is a required empty block for the azurerm provider, forgetting it is a classic first-run error.

Common mistake: committing backend credentials or (worse) the state file itself to git. The state blob can hold secrets, storage keys, generated passwords, in plaintext. Store it in the locked-down storage account from the setup section, never in the repository.

What this teaches: Remote state is what makes Terraform a team tool; local state is a single point of failure and confusion.

03
Write the equivalent Terraform

Now express the same infrastructure in Terraform's HCL. To keep the comparison honest, this configuration manages the same resource group and adds a second storage account, Terraform and Bicep each manage their own resource here, but through the same resource group, so you can see both tools' outputs side by side in one place. (Two IaC tools managing the same resource is an anti-pattern you should avoid in real environments; here the separation is explicit.)

TERRAFORM CONFIGURATION, main.tf

resource "azurerm_storage_account" "iac" {
  name                     = "tyftfdemo01"
  resource_group_name      = "tyf-rg"
  location                 = "canadacentral"
  account_tier             = "Standard"
  account_replication_type = "LRS"
  account_kind             = "StorageV2"
  min_tls_version          = "TLS1_2"

  tags = {
    deployedBy  = "terraform"
    environment = "dev"
  }
}

output "storage_account_id" {
  value = azurerm_storage_account.iac.id
}

Expected output:

$ terraform validate
Success! The configuration is valid.

The block label pair, "azurerm_storage_account" and "iac", is the resource's address inside Terraform (azurerm_storage_account.iac), and it is this address, not the Azure name, that the state file tracks. Run terraform fmt and terraform validate now; fmt normalizes layout, validate checks syntax and internal consistency against the provider schema.

How to verify: terraform validate must be run after terraform init (next step) because it needs the provider schema downloaded. If you run it first and get Missing or corrupted provider plugins, that is expected, init, then validate.

What this teaches: HCL describes the same desired state in a different syntax; the concepts of plan, apply, and drift transfer directly.

04
Init, plan, and apply with Terraform

terraform init does three jobs: downloads the azurerm provider from the registry, configures the remote backend, and migrates state into it (there is nothing to migrate on a first run). Watch its output for the backend confirmation line, if init silently falls back to a local backend, everything that follows loses the locking and sharing you set up.

$ terraform init
Initializing the backend... Successfully configured the backend "azurerm"! Terraform will automatically use this backend unless the backend configuration changes. Initializing provider plugins... - Finding hashicorp/azurerm versions matching "~> 3.97"... - Installing hashicorp/azurerm v3.97.1... Terraform has been successfully initialized!

Then the core workflow, chained: plan -out tf.plan computes the change set and saves it to a file, and apply tf.plan executes exactly that saved plan, not a recomputation of it. Saving the plan and applying the file (rather than running a bare terraform apply) guarantees that what you reviewed is what runs, which is why pipelines always use this two-step form.

terraform init && terraform plan -out tf.plan && terraform apply tf.plan
$ terraform plan -out tf.plan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # azurerm_storage_account.iac will be created + resource "azurerm_storage_account" "iac" { + account_replication_type = "LRS" + account_tier = "Standard" + location = "canadacentral" + name = "tyftfdemo01" + resource_group_name = "tyf-rg" ... } Plan: 1 to add, 0 to change, 0 to destroy. Saved the plan to: tf.plan
$ terraform apply tf.plan
azurerm_storage_account.iac: Creating... azurerm_storage_account.iac: Creation complete after 26s [id=/subscriptions/1111.../storageAccounts/tyftfdemo01] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: storage_account_id = "/subscriptions/1111.../resourceGroups/tyf-rg/providers/Microsoft.Storage/storageAccounts/tyftfdemo01"
Common mistake: editing main.tf between plan -out and apply. Terraform refuses to apply a stale saved plan (plan file was created with a different configuration), which is a feature, re-run plan and review the fresh output instead of working around the guard.

Expected output:

Plan: 2 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

What this teaches: Plan is the what-if of Terraform; reading the diff before applying is the habit that prevents outages.

05
Detect drift

Drift is any difference between your code and reality. Create some deliberately: change a tag on the Terraform-managed storage account through the Azure CLI, simulating a well-meaning colleague editing in the portal. Then run a plain plan. Terraform refreshes its view of the live resource first, then compares, and reports the difference as a change it wants to make.

SIMULATE OUT-OF-BAND CHANGE, THEN PLAN

# A "portal edit", done via CLI for repeatability:
az resource tag --ids $(az storage account show -n tyftfdemo01 -g tyf-rg --query id -o tsv) \
  --set tags.environment=production

terraform plan -detailed-exitcode; echo "exit=$?"

Expected output:

$ terraform plan -detailed-exitcode
# azurerm_storage_account.demo will be updated in-place
~ tags = { "env" = "lab" -> "prod" }
Plan: 0 to add, 1 to change, 0 to destroy.
$ terraform plan -detailed-exitcode
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: ~ update in-place Terraform will perform the following actions: # azurerm_storage_account.iac will be updated in-place ~ resource "azurerm_storage_account" "iac" { id = "/subscriptions/1111.../storageAccounts/tyftfdemo01" name = "tyftfdemo01" ~ tags = { ~ "environment" = "production" -> "dev" # (1 unchanged element hidden) } # (23 unchanged attributes hidden) } Plan: 0 to add, 1 to change, 0 to destroy.

The -detailed-exitcode flag makes the result scriptable: exit 0 means no drift, exit 1 means an error, exit 2 means drift (changes pending). With no drift, the output is the one-liner below, this is the state you want to return to after remediation.

$ terraform plan -detailed-exitcode
No changes. Your infrastructure matches the configuration.

Bicep's equivalent drift check is what-if: re-run az deployment group what-if against main.bicep and any out-of-band change to tyfiac01 shows up as a ~ modify entry. Same idea, stateless implementation, ARM reads live state every time instead of consulting a state file.

How to verify: after the tag edit, the plan must show exit=2. If it shows exit 0 while you know the tag was changed, the refresh is being skipped, make sure you are not passing -refresh=false and that you are in the right workspace/directory.

What this teaches: Drift detection proves the state file is a snapshot, not a live view; reality always wins.

06
Remediate drift through code

The fix for drift is not to edit the resource back by hand, that just creates a new unrecorded change. The fix is to re-apply the code, letting the tool converge reality onto the declared state. For Terraform that is another apply of a fresh plan; for the Bicep-managed resource it is re-running the same deployment. Afterwards, both drift checks should come back clean.

CONVERGE AND CONFIRM

terraform plan -out fix.plan && terraform apply fix.plan
terraform plan -detailed-exitcode; echo "exit=$?"          # expect exit=0

az deployment group create -g tyf-rg --template-file main.bicep --parameters name=tyfiac01 --query properties.provisioningState
az deployment group what-if -g tyf-rg --template-file main.bicep --parameters name=tyfiac01

Expected output:

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

Leave everything running. The Testing & Validation section checks this live environment, and cleanup happens at its end, not here.

Common mistake: deleting the state storage account while Terraform configurations still point at it. Every subsequent terraform plan then fails with a backend initialization error until you either restore the backend or re-run terraform init -migrate-state against a new one. Destroy the managed resources first, the backend last.

What this teaches: Remediation through code keeps the repository as the single source of truth; manual fixes are undocumented by definition.

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 milestones from the original scope, the Bicep deployment, the Terraform apply, and the drift check, are all complete, with remote state configured along the way and drift actually induced, observed, and remediated rather than only discussed.

How It Works

Bicep and Terraform solve the same problem with different architectures. Bicep is a friendlier syntax that compiles down to ARM JSON and deploys through the same ARM control plane as the portal and CLI; there is no separate state file because ARM itself is the source of truth. Terraform keeps its own state file mapping your configuration to real-world IDs, which lets one tool manage many clouds but makes that state file the most important artifact in the workflow.

terraform plan is a diff between three things: your configuration, the state file, and the live infrastructure. Drift is what happens when someone changes reality outside Terraform: the next plan surfaces it as an unexpected change. This is a feature, not a bug; it is how you discover that the portal click someone made at 2 AM is now load-bearing.

Remote state with locking is what turns Terraform from a laptop tool into a team tool. The state lives in blob storage where everyone reads the same copy, and the lease-based lock means two applies cannot corrupt it concurrently. If you take one operational lesson from this lab, it is this: never let two writers touch local state.

Testing & Validation

These checks verify the live lab. Run them in order, then clean up at the end of this section.

az resource list -g tyf-rg -o table && terraform plan --detailed-exitcode; echo "exit=$?"

An exit code of 0 from plan means no drift. If resources exist and plan is clean, your infrastructure matches code.

$ az resource list -g tyf-rg -o table
Name ResourceGroup Location Type ---------- ------------- ------------- --------------------------------- tyfiac01 tyf-rg canadacentral Microsoft.Storage/storageAccounts tyftfdemo01 tyf-rg canadacentral Microsoft.Storage/storageAccounts

The listing shows both tools' resources coexisting in one group: tyfiac01 came from Bicep, tyftfdemo01 from Terraform. That is the visible proof that both deployments landed. Three more checks complete the validation picture:

FULL VALIDATION PASS

# 1. Bicep side: what-if reports no changes (idempotent deployment).
az deployment group what-if -g tyf-rg --template-file main.bicep --parameters name=tyfiac01

# 2. Terraform side: state knows about exactly the resource you expect.
terraform state list
terraform state show azurerm_storage_account.iac | grep -E "^\s+id\s+="

# 3. State backend: the blob exists and carries a lease when a run is active.
az storage blob list --container-name tfstate --account-name tyftfstate01 --auth-mode login -o table
How to verify: what-if should end with Resource changes: no change., terraform state list should print exactly azurerm_storage_account.iac, and the blob listing should show iac-lab.tfstate. Any disagreement among these three means one tool's view has diverged, investigate before proceeding, because the next apply will act on whichever view it trusts.

When the lab is done, remove what you built so it stops billing. Terraform resources go with terraform destroy; the Bicep-deployed account is deleted directly (Incremental-mode deployments never delete on their own). Delete the state storage last, after Terraform no longer needs it.

CLEAN UP ALL LAB RESOURCES

terraform destroy                        # removes tyftfdemo01 (review the destroy plan!)
az storage account delete --name tyfiac01 --resource-group tyf-rg --yes
# tyf-rg stays: it is shared with the other Cloud and Azure labs.
az group delete --name tyf-tfstate-rg --yes --no-wait   # state backend goes last

Expected output:

$ terraform destroy -auto-approve
Destroy complete! Resources: 1 destroyed.
Common mistake: deleting the state storage account while Terraform configurations still point at it. Every subsequent terraform plan then fails with a backend initialization error until you either restore the backend or re-run terraform init -migrate-state against a new one. Destroy the managed resources first, the backend last.

Troubleshooting

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

SymptomLikely causeFix
bicep build failsSyntax error in the Bicep fileRun az bicep build and read the first error; the compiler points at the exact line.
terraform init failsProvider download blocked by network or proxyRetry with proxy variables set; providers cache under ~/.terraform.d after the first success.
State lock held after a crashed applyThe lock was never releasedConfirm no apply is running, then terraform force-unlock with the lock ID; never unlock while an apply is live.
Plan shows changes you did not makeDrift: someone edited the resource outside TerraformInvestigate the diff; either import the change or let the next apply revert it, deliberately.
Apply fails halfwayA dependent resource rejected the changeFix the root cause and re-apply; Terraform resumes from the updated state, not from scratch.
Troubleshooting
  • State lock errors: a previous run is holding the lock; wait or force-unlock carefully. Terraform prints the lock info, including a Lock ID, confirm no run is genuinely active (a crashed apply or a closed laptop can leave a stale lease), then release it with terraform force-unlock <LOCK_ID>. Never force-unlock while a teammate's apply might still be running; the lock is the only thing standing between you and a corrupt state file.
  • Drift keeps returning: stop editing in the portal; remediate only through code. If drift reappears after every apply, something automated is making the change, a policy assignment, a scheduled runbook, or a colleague's script. Find the actor with az monitor activity-log list --resource-group tyf-rg --max-events 20 -o table and look at the caller column.
  • Bicep build error: run az bicep build to surface the line. The compiler reports file, line, and a diagnostic code (e.g. BCP057 for a name that does not exist in scope), fix the first reported error and rebuild, since later errors are often cascading noise from the first.
  • Backend init fails with AuthorizationFailed or 403 on the state blob: your account has management rights on the storage account but no data-plane role on the container. Add Storage Blob Data Contributor as shown in the setup section, wait a minute for RBAC propagation, and re-run terraform init.
  • Plan wants to destroy and recreate a resource you only renamed: Terraform tracks resources by their address (azurerm_storage_account.iac), so renaming the block label looks like "delete old, create new." Use terraform state mv azurerm_storage_account.old_name azurerm_storage_account.new_name to rename the address in state instead of letting the resource be rebuilt. Note that renaming the Azure name property of a storage account genuinely is a recreate, the name is immutable once created, and the plan is correct.

Extension Ideas

  • Run plan/apply in CI/CD with policy checks.
  • Add module reuse and environments (dev/prod) with workspaces.
  • Continuously audit posture with CSPM.
  • Break the growing main.tf into modules with versioned sources, and do the same in Bicep with module blocks referencing shared files.
  • Add a scheduled drift-detection job that runs terraform plan -detailed-exitcode nightly and opens an issue when exit code 2 appears.

Key Results

  • Provisioned identical resources via Bicep and Terraform.
  • Managed Terraform state remotely with locking.
  • Detected zero drift after apply via plan exit codes.
  • Made infrastructure version-controlled and reviewable.
  • Induced drift deliberately, watched plan and what-if catch it, and remediated through code alone.

Key Takeaways

  • Preview before apply, always: what-if for Bicep, plan -out for Terraform. The preview is the same evaluation the apply will execute.
  • Bicep is stateless, it asks Azure what exists on every run. Terraform keeps a state file, which buys exact, fast plans at the cost of having to protect and lock that file.
  • Remote state with blob-lease locking is what makes Terraform team-safe; a local terraform.tfstate is a solo-only convenience.
  • -detailed-exitcode turns drift detection into a scriptable signal: 0 clean, 2 drifted. That exit code is what a pipeline gates on.
  • Drift is remediated by re-applying code, never by hand-editing back, the code is the source of truth, or it is nothing.
  • Save the plan to a file and apply the file, so what you reviewed is byte-for-byte what runs.

Practice Challenges

Challenge 1: Parameterize the environment

Goal: Convert the Bicep template to accept an env parameter that changes names and SKUs between dev and prod.

Hint: Use a variable map keyed by env so the template body stays clean.

Challenge 2: Import a manual resource

Goal: Create a resource in the portal, then terraform import it into state without recreating it.

Hint: Write the configuration block first, then import; the next plan should show no changes.

Challenge 3: Prove locking works

Goal: Start two applies at once and observe the second wait on the state lock.

Hint: Use a null_resource with a sleep provisioner to widen the window.