Infrastructure as code27 min read

Terraform on Azure

Build Azure infrastructure with providers, remote state, reusable modules, secure CI/CD workflows, imports, and practical troubleshooting.

Production-aware guide

Review scope, permissions, impact, and rollback before applying changes.

Terraform on Azure

Terraform is an infrastructure-as-code tool that compares declarative configuration with recorded state and real infrastructure, then proposes an execution plan. On Azure, the AzureRM provider manages resources through Azure APIs.

The safe workflow is: write configuration, format it, initialize providers, validate it, review a saved plan, apply the reviewed plan, and verify the resulting resources.

Who this guide is for

This guide covers:

  • Terraform configuration and provider concepts.
  • Azure authentication.
  • Remote state in Azure Storage.
  • Resource definitions, variables, outputs, and modules.
  • CI/CD safety and security.
  • Importing existing Azure resources.
  • Common provider, state, and deployment failures.

Core concepts

Provider

A provider plugin communicates with an external API. The azurerm provider manages Azure resources. Pin provider versions deliberately and review upgrade notes before changing them.

Resource

A resource block declares infrastructure Terraform should manage, such as a resource group, virtual network, or App Service plan.

Data source

A data source reads existing information without declaring ownership of that object. Use it when configuration needs details of an existing resource.

State

State maps Terraform resource addresses to real objects. Terraform uses it to calculate changes. State can contain sensitive values, so protect it as production data.

Plan

A plan describes proposed actions. Review additions, updates, replacements, and deletions before applying it. A successful plan is not proof that the apply will succeed because permissions, policies, quotas, locks, or remote conditions can change.

Module

A module packages related resources behind inputs and outputs. Modules reduce repetition but require versioning, documentation, testing, and clear ownership.

Install and verify Terraform

Install Terraform using the official HashiCorp instructions for the operating system. Then verify:

terraform version

Install Azure CLI and authenticate for local development:

az login
az account set --subscription <subscription-id>
az account show --query '{name:name,id:id,tenantId:tenantId}' --output table

Interactive login is suitable for local work. CI/CD should use workload identity federation, managed identity, or another approved non-interactive method.

Project structure

A small root module may use:

terraform/
├── versions.tf
├── providers.tf
├── main.tf
├── variables.tf
├── outputs.tf
└── environments/
    ├── dev.tfvars
    └── prod.tfvars

Do not commit secret values in .tfvars files. Add local secret files and Terraform working data to .gitignore:

.terraform/
*.tfstate
*.tfstate.*
crash.log
*.tfplan
*.auto.tfvars
*.auto.tfvars.json

The dependency lock file .terraform.lock.hcl should normally be committed so provider selections are reproducible.

Provider configuration

versions.tf:

terraform {
  required_version = ">= 1.8.0, < 2.0.0"

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

providers.tf:

provider "azurerm" {
  features {}
  subscription_id = var.subscription_id
}

Version constraints above are examples, not permanent recommendations. Confirm compatibility with the current Terraform and AzureRM releases before publishing or upgrading.

Create an Azure resource group

variables.tf:

variable "subscription_id" {
  description = "Azure subscription that will contain the resources."
  type        = string
}

variable "location" {
  description = "Azure region for the deployment."
  type        = string
  default     = "westeurope"
}

variable "environment" {
  description = "Short environment identifier."
  type        = string

  validation {
    condition     = contains(["dev", "test", "prod"], var.environment)
    error_message = "environment must be dev, test, or prod."
  }
}

main.tf:

locals {
  common_tags = {
    environment = var.environment
    managed_by  = "terraform"
    workload    = "cloudforge-example"
  }
}

resource "azurerm_resource_group" "main" {
  name     = "rg-cloudforge-${var.environment}"
  location = var.location
  tags     = local.common_tags
}

outputs.tf:

output "resource_group_id" {
  description = "Resource ID of the created resource group."
  value       = azurerm_resource_group.main.id
}

Run the workflow:

terraform fmt -check -recursive
terraform init
terraform validate
terraform plan -var-file=environments/dev.tfvars -out=dev.tfplan
terraform show dev.tfplan
terraform apply dev.tfplan

The saved plan ensures the apply uses the reviewed proposal, provided the state and environment have not invalidated it.

Remote state in Azure Storage

Local state is unsuitable for shared production workflows. A common Azure backend uses a storage account and blob container.

terraform {
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "<globally-unique-storage-name>"
    container_name       = "tfstate"
    key                  = "cloudforge/prod.tfstate"
  }
}

Initialize or migrate the backend:

terraform init -reconfigure

Protect the backend:

  • Restrict RBAC to the automation identities and administrators that need it.
  • Prefer Entra authentication over storage access keys.
  • Disable unnecessary public network access.
  • Enable storage protections appropriate to recovery requirements.
  • Separate state by environment and access boundary.
  • Monitor access and changes.
  • Never expose state in build logs or artifacts.

Do not manually edit a state blob. Use Terraform state commands only after backing up state and confirming the exact target.

Azure authentication patterns

Local development

Use Azure CLI authentication and select the intended subscription explicitly.

Managed identity

Use managed identity when the runner is hosted on a compatible Azure resource. Assign only the roles needed at the narrowest practical scope.

Workload identity federation

Use federation for supported CI/CD systems to avoid stored client secrets. Configure trust conditions narrowly around repository, branch, environment, or pipeline identity.

Service principal secret

Use only where stronger options are unavailable. Store the secret in the CI platform's protected credential store, rotate it, and avoid exposing it through command tracing.

Dependencies

Terraform usually infers dependencies from references:

resource "azurerm_virtual_network" "main" {
  name                = "vnet-cloudforge-${var.environment}"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.20.0.0/16"]
}

Use depends_on only for a real dependency Terraform cannot infer. Excessive explicit dependencies slow operations and make the graph harder to understand.

Build reusable modules

Suggested structure:

modules/
└── resource-group/
    ├── main.tf
    ├── variables.tf
    ├── outputs.tf
    └── README.md

Call the module:

module "resource_group" {
  source = "./modules/resource-group"

  name     = "rg-cloudforge-${var.environment}"
  location = var.location
  tags     = local.common_tags
}

A good module has a focused purpose, typed inputs, useful validation, documented outputs, examples, automated tests, and a release/version policy.

Avoid building a giant module that hides every Azure decision behind dozens of optional variables.

Import existing Azure resources

Import connects an existing object to a Terraform resource address. First write configuration that represents the resource, then review the import target.

Example import block:

import {
  to = azurerm_resource_group.existing
  id = "/subscriptions/<subscription-id>/resourceGroups/<resource-group>"
}

Run:

terraform plan

Import does not automatically guarantee that the written configuration matches every remote property. Review the plan carefully so the first apply does not unexpectedly modify or replace the resource.

CI/CD pipeline

A safe pull-request pipeline usually performs:

terraform fmt -check -recursive
terraform init -input=false
terraform validate
terraform plan -input=false -out=tfplan
terraform show -no-color tfplan

The protected deployment job should apply a reviewed plan with an authorized identity. Separate plan and apply permissions where the operating model requires it.

Recommended controls:

  • Pin Terraform and provider versions.
  • Lock state remotely.
  • Run plans for every infrastructure change.
  • Require review for production changes.
  • Prevent untrusted pull requests from accessing production credentials.
  • Scan configuration for policy and security problems.
  • Preserve an auditable plan summary.
  • Serialize applies against the same state.
  • Verify resources after apply.

Destructive operations

Terraform may replace a resource when an immutable property changes. In the plan, replacement is represented as destroy-and-create or create-and-destroy depending on lifecycle and provider behavior.

Before approving a destructive plan:

  1. Confirm the correct workspace, backend, subscription, and environment.
  2. Identify data-bearing resources.
  3. Confirm backups and restoration procedures.
  4. Review dependencies and downtime.
  5. Check whether Azure locks or policies will block the action.
  6. Obtain the required approval.

Avoid routine use of -auto-approve in production.

Terraform language essentials

Terraform configuration uses blocks, arguments, expressions, and values. Use explicit types and descriptions for module interfaces.

variable "subnets" {
  description = "Map of subnet definitions keyed by stable logical name."
  type = map(object({
    address_prefix = string
    service_endpoints = optional(set(string), [])
  }))
}

Stable map keys are usually safer than list indexes for resources whose order may change.

resource "azurerm_subnet" "this" {
  for_each = var.subnets

  name                 = each.key
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = [each.value.address_prefix]
  service_endpoints    = each.value.service_endpoints
}

Changing a for_each key changes the Terraform resource address and may cause replacement unless the move is declared. Choose keys from stable identity, not display order.

Locals and outputs

Local values give a complex expression one meaningful name:

locals {
  name_prefix = "cf-${var.environment}-${var.location_code}"

  required_tags = merge(var.tags, {
    environment = var.environment
    managed_by  = "terraform"
  })
}

Outputs expose selected information to users, automation, or other configurations:

output "web_app_hostname" {
  description = "Default hostname assigned to the web application."
  value       = azurerm_linux_web_app.main.default_hostname
}

Mark a sensitive output with sensitive = true, but remember the value remains in state and may still be accessible to anyone with state permission.

Meta-arguments

for_each and count

Use for_each when instances have stable names or keys. Use count for interchangeable instances where numeric indexing will remain meaningful.

depends_on

Use it only when a dependency exists but is not visible in an expression. Broad dependencies can delay reads and produce unnecessarily conservative plans.

lifecycle

Lifecycle rules change how Terraform handles resources:

lifecycle {
  prevent_destroy = true

  precondition {
    condition     = var.environment == "prod" ? var.zone_redundant : true
    error_message = "Production must use zone-redundant capacity."
  }
}

prevent_destroy is a guardrail inside this configuration, not a substitute for Azure resource locks, RBAC, backups, and approval. ignore_changes can conceal drift; use it only when another system intentionally owns the ignored property.

Data sources and ownership

A data source reads an object without adding it to Terraform ownership:

data "azurerm_client_config" "current" {}

data "azurerm_resource_group" "shared" {
  name = var.shared_resource_group_name
}

Before choosing a resource or data source, decide which system owns lifecycle changes. Multiple tools managing the same property lead to recurring drift and unsafe overwrites.

Environments and workspaces

Terraform CLI workspaces provide multiple states for one configuration, but they are not a strong boundary for environments that require different credentials, access controls, backends, or ownership.

For materially different production boundaries, prefer separate root modules or directories with separate backend keys and pipeline permissions. Keep reusable logic in versioned child modules.

A clear layout might be:

live/
├── dev/
│   ├── backend.hcl
│   └── main.tf
├── test/
│   ├── backend.hcl
│   └── main.tf
└── prod/
    ├── backend.hcl
    └── main.tf

Avoid copying full resource definitions between environments. Compose each root module from common modules and explicit environment settings.

State operations and recovery

Treat state commands as administrative operations.

Inspect state

terraform state list
terraform state show 'azurerm_resource_group.main'

Rename or move an address

Prefer a configuration moved block because it documents the refactor:

moved {
  from = azurerm_resource_group.main
  to   = module.foundation.azurerm_resource_group.main
}

Remove an address

terraform state rm stops Terraform from managing the object; it does not delete the remote object. Use it only when lifecycle ownership is intentionally transferred and documented.

Before any state mutation:

  1. Stop concurrent runs.
  2. Confirm the backend and workspace.
  3. Back up the current state.
  4. Resolve the exact resource address.
  5. Preview the consequence.
  6. Perform the smallest mutation.
  7. Run a fresh plan and verify ownership.

Complete Azure application foundation

A production application foundation often includes:

  • Resource group and required tags.
  • Virtual network, subnets, DNS, and private endpoints.
  • Managed identity.
  • Key Vault with RBAC and restricted networking.
  • Container registry or application hosting plan.
  • Application Insights and Log Analytics.
  • Diagnostic settings.
  • Alerts and action groups.
  • Policy-compliant locks and retention.

Keep these capabilities in focused modules. The root module should make environment-level decisions explicit:

module "web_application" {
  source = "git::https://example.invalid/platform-modules.git//modules/web-app?ref=v2.3.0"

  name                = "${local.name_prefix}-app"
  resource_group_name = module.foundation.resource_group_name
  location            = var.location
  subnet_id           = module.network.app_subnet_id
  identity_id         = module.identity.id
  tags                = local.required_tags
}

Replace the example source with an approved repository. Pin module versions so a normal initialization does not silently consume unreviewed module changes.

Testing Terraform

Use several layers:

  1. terraform fmt -check for canonical formatting.
  2. terraform validate for configuration validity.
  3. Provider-backed plan in a controlled environment.
  4. terraform test for module behavior and assertions where appropriate.
  5. Static security and policy checks.
  6. Deployment tests in an isolated subscription or resource group.
  7. Post-apply verification against Azure and the application.

Example test file:

run "resource_group_name_contains_environment" {
  command = plan

  variables {
    environment     = "test"
    subscription_id = "00000000-0000-0000-0000-000000000000"
  }

  assert {
    condition     = azurerm_resource_group.main.name == "rg-cloudforge-test"
    error_message = "Resource group naming did not include the environment."
  }
}

Tests that interact with Azure may create billable resources and require cleanup. Use dedicated credentials and isolation.

Policy and governance

Combine Terraform controls with Azure governance:

  • Azure Policy validates or modifies allowed resource configuration.
  • RBAC controls which identities can read plans, state, and resources.
  • Resource locks reduce accidental deletion or modification.
  • Management-group structure applies organization-level policy.
  • CI policy checks block known unsafe configuration before Azure receives it.

Do not make Terraform responsible for bypassing policy. A policy failure should be diagnosed against the policy assignment, parameters, exemption, and resource payload.

Dependency and provider upgrades

Upgrade deliberately:

terraform init -upgrade
terraform providers
terraform plan

Perform upgrades in a branch, review lock-file changes, read provider migration notes, and test representative modules. Major provider upgrades can change defaults, schemas, or resource behavior even when configuration still validates.

Cost and operational awareness

Infrastructure as code can create expensive resources quickly. Add review around high-cost SKUs, public egress, log ingestion, backup retention, managed disks, node pools, and database capacity.

Cost estimates are advisory and can miss runtime consumption. Combine code review, budget alerts, tagging, ownership, and post-deployment monitoring.

Troubleshooting decision table

SymptomEvidenceLikely causeSafe next step
Init failsRegistry/backend errorNetwork, auth, source, lock fileVerify backend and provider access
Plan proposes mass creationBackend/workspace outputWrong or empty stateStop and confirm backend identity
Plan proposes replacementChanged force-new attributeImmutable property or address changeReview provider docs and moved blocks
State lock errorLock metadataActive or abandoned runConfirm ownership before unlock
Authorization failurePrincipal and Azure scopeMissing RBAC actionIdentify exact action and narrow role
Policy denialAzure inner errorAssigned policyInspect policy definition and parameters
Drift returns repeatedlyPlan diff and activity logsAnother owner modifies propertyAssign one configuration owner
Apply partially succeedsState plus Azure activityLater resource failurePreserve state, fix cause, plan again

Troubleshooting

Terraform uses the wrong subscription

Check both Azure CLI and provider configuration:

az account show --output table
terraform providers

Set the subscription explicitly and confirm CI variables. Never infer the correct production subscription from a friendly display name alone.

Provider registration error

The identity may lack permission to register Azure resource providers, or automatic registration behavior may not match the environment policy. Determine the required namespace, confirm organizational policy, and have an authorized administrator register it when appropriate.

AuthorizationFailed

Confirm the identity, target scope, required action, role assignment, and propagation time. A Contributor role does not grant every identity-management permission.

State lock cannot be acquired

First confirm whether another plan or apply is still running. Do not force-unlock active work. If the lock is stale, capture the lock identifier, verify no process owns it, back up state, and then use the documented unlock procedure.

Resource already exists

Terraform cannot create a resource whose name or ID already exists. Decide whether to import the resource, reference it with a data source, rename the new resource, or manage it outside this state. Do not delete an existing resource merely to make the apply succeed.

Configuration changed outside Terraform

Run terraform plan to evaluate drift. Decide whether the remote change should be represented in code or reverted. Establish ownership so multiple tools do not continuously overwrite one another.

A resource will be replaced unexpectedly

Inspect the changed argument and provider documentation. Check for renamed resources, changed keys in for_each, module address changes, provider upgrades, and immutable Azure properties. Use moved blocks when refactoring resource addresses.

terraform init cannot download a provider

Check network/proxy settings, registry access, the lock file, platform compatibility, and provider source address. In restricted environments, use an approved provider mirror.

Sensitive output appears in logs

Mark outputs as sensitive and stop printing raw plan or state data in shared logs. Remember that sensitive = true reduces display but does not remove the value from state.

Command reference

terraform fmt -recursive
terraform init
terraform validate
terraform plan
terraform apply
terraform output
terraform state list
terraform state show <address>
terraform providers
terraform show
terraform workspace show

State mutation commands are advanced operations. Back up state and verify the exact resource address before using state mv, state rm, or force-unlock.

Frequently asked questions

Should Terraform state be committed to Git?

No. State may contain sensitive data and changes frequently. Store shared state in a protected remote backend.

Is a successful plan safe to apply automatically?

Not always. Review replacement and deletion actions, confirm the target environment, and apply governance appropriate to the impact.

Terraform or Bicep for Azure?

Bicep is Azure-native and tightly aligned with Azure Resource Manager. Terraform offers a consistent workflow across many providers and a broad module ecosystem. Choose based on platform scope, team skills, governance, and operating model.

Can Terraform manage resources created manually?

Yes, after writing matching configuration and importing the resources. Review the first post-import plan carefully.

Official references