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:
- Confirm the correct workspace, backend, subscription, and environment.
- Identify data-bearing resources.
- Confirm backups and restoration procedures.
- Review dependencies and downtime.
- Check whether Azure locks or policies will block the action.
- 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:
- Stop concurrent runs.
- Confirm the backend and workspace.
- Back up the current state.
- Resolve the exact resource address.
- Preview the consequence.
- Perform the smallest mutation.
- 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:
terraform fmt -checkfor canonical formatting.terraform validatefor configuration validity.- Provider-backed plan in a controlled environment.
terraform testfor module behavior and assertions where appropriate.- Static security and policy checks.
- Deployment tests in an isolated subscription or resource group.
- 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
| Symptom | Evidence | Likely cause | Safe next step |
|---|---|---|---|
| Init fails | Registry/backend error | Network, auth, source, lock file | Verify backend and provider access |
| Plan proposes mass creation | Backend/workspace output | Wrong or empty state | Stop and confirm backend identity |
| Plan proposes replacement | Changed force-new attribute | Immutable property or address change | Review provider docs and moved blocks |
| State lock error | Lock metadata | Active or abandoned run | Confirm ownership before unlock |
| Authorization failure | Principal and Azure scope | Missing RBAC action | Identify exact action and narrow role |
| Policy denial | Azure inner error | Assigned policy | Inspect policy definition and parameters |
| Drift returns repeatedly | Plan diff and activity logs | Another owner modifies property | Assign one configuration owner |
| Apply partially succeeds | State plus Azure activity | Later resource failure | Preserve 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.