Category Recommendation
Best Practice Use service principals for authentication, organize resources with modules
Security Store state files in encrypted Azure Storage, never commit secrets to Git
Performance Use remote state backends, enable state locking with Azure Storage
Cost Optimization Tag all resources, use terraform plan before apply, implement lifecycle rules
Monitoring Enable Azure Activity Log integration, use Terraform Cloud for team collaboration

TL;DR

  • Terraform transforms Azure infrastructure management from manual clicks to reproducible, version-controlled code
  • Authentication via service principals provides secure, automated access to Azure resources without human intervention
  • Remote state storage in Azure Blob Storage enables team collaboration and prevents configuration drift
  • Resource tagging and lifecycle management keeps costs predictable and environments organized
  • Modular configuration patterns make complex Azure deployments maintainable and reusable across projects

Introduction

Managing Azure infrastructure through the portal becomes unwieldy as your cloud footprint grows. Clicking through menus, remembering configuration details, and ensuring consistency across environments quickly turns into a full-time job fraught with human error.

Terraform solves this infrastructure-as-code challenge by treating your Azure resources as declarative configuration files. You describe what you want, Terraform figures out how to make it happen, and you get reproducible deployments that can be version-controlled, peer-reviewed, and automated. Microsoft maintains its own Terraform on Azure documentation covering the azurerm provider and related tooling.

This terraform azure tutorial walks you through building real Azure infrastructure—from a simple resource group to a complete web application stack. You'll learn authentication best practices, state management, and the modular patterns that make Terraform maintainable at scale. By the end, you'll have hands-on experience with the tools and techniques used by Azure DevOps teams worldwide.

Prerequisites

  • Azure subscription with Contributor or Owner role
  • Azure CLI 2.55.0 or later installed and configured
  • Terraform 1.6.0+ installed (latest stable recommended)
  • Basic understanding of Azure resource hierarchy (subscriptions, resource groups)
  • Command-line comfort with Bash or PowerShell
  • Git installed for version control (optional but recommended)

Setting Up Terraform Azure Authentication

Creating a Service Principal

The most secure way to authenticate Terraform with Azure is through a service principal—essentially a non-human identity that Terraform uses to manage resources.

# Login to Azure CLI
az login

# Get your subscription ID
az account show --query id --output tsv

# Create service principal with Contributor role
az ad sp create-for-rbac \
  --name "terraform-sp" \
  --role="Contributor" \
  --scopes="/subscriptions/YOUR_SUBSCRIPTION_ID"

Save the output securely—you'll need these values:

{
  "appId": "12345678-1234-1234-1234-123456789012",
  "displayName": "terraform-sp",
  "password": "super-secret-password",
  "tenant": "87654321-4321-4321-4321-210987654321"
}

Configuring Environment Variables

Set these environment variables to authenticate Terraform:

# Linux/macOS
export ARM_SUBSCRIPTION_ID="YOUR_SUBSCRIPTION_ID"
export ARM_TENANT_ID="87654321-4321-4321-4321-210987654321"
export ARM_CLIENT_ID="12345678-1234-1234-1234-123456789012"
export ARM_CLIENT_SECRET="super-secret-password"
# PowerShell
$env:ARM_SUBSCRIPTION_ID = "YOUR_SUBSCRIPTION_ID"
$env:ARM_TENANT_ID = "87654321-4321-4321-4321-210987654321"
$env:ARM_CLIENT_ID = "12345678-1234-1234-1234-123456789012"
$env:ARM_CLIENT_SECRET = "super-secret-password"

Your First Terraform Azure Configuration

Project Structure Setup

Create a clean project structure for your terraform azure tutorial:

mkdir terraform-azure-demo
cd terraform-azure-demo

# Create essential files
touch main.tf variables.tf outputs.tf terraform.tf

Basic Configuration Files

terraform.tf – Provider and backend configuration:

terraform {
  required_version = ">= 1.6.0"
  
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.0"
    }
  }
}

provider "azurerm" {
  features {}

  # Required for plan and apply since v4.0. Leave the variable null to
  # inherit ARM_SUBSCRIPTION_ID from the environment you set up above.
  subscription_id = var.subscription_id

  # v5.0 registers no resource providers by default — declare the ones
  # this configuration actually needs.
  resource_providers_to_register = [
    "Microsoft.Network",
    "Microsoft.Storage",
    "Microsoft.DBforMySQL",
  ]
}

Several things changed since the 3.x examples still floating around. The azurerm provider is on 5.x, features {} is a required block rather than a nicety, and the subscription ID must be explicit for plan and apply — the 4.0 upgrade stopped silently inheriting whatever subscription your Azure CLI happened to have selected. The 5.0 upgrade then flipped resource_provider_registrations to none, so a brand-new subscription no longer gets ~60 providers registered for you. That is a faster terraform init, but it means an apply fails with a resource provider error unless you list what you need.

variables.tf – Input parameters:

variable "resource_group_name" {
  description = "Name of the Azure Resource Group"
  type        = string
  default     = "rg-terraform-demo"
}

variable "location" {
  description = "Azure region for resources"
  type        = string
  default     = "East US"
}

variable "environment" {
  description = "Environment name (dev, staging, prod)"
  type        = string
  default     = "dev"
}

variable "project_name" {
  description = "Project identifier for resource naming"
  type        = string
  default     = "tfdemo"
}

variable "subscription_id" {
  description = "Azure Subscription ID. Leave null to inherit ARM_SUBSCRIPTION_ID"
  type        = string
  default     = null
}

variable "mysql_admin_password" {
  description = "MySQL administrator password — supply via TF_VAR_mysql_admin_password or Key Vault, never a default"
  type        = string
  sensitive   = true
}

Note the deliberate absence of a default on mysql_admin_password. A default would be a password committed to Git, which is the thing we are trying to avoid.

main.tf – Core resource definitions:

# Create Resource Group
resource "azurerm_resource_group" "main" {
  name     = var.resource_group_name
  location = var.location

  tags = {
    Environment = var.environment
    Project     = var.project_name
    ManagedBy   = "Terraform"
    CreatedDate = timestamp()
  }
}

# Create Virtual Network
resource "azurerm_virtual_network" "main" {
  name                = "${var.project_name}-vnet-${var.environment}"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  tags = {
    Environment = var.environment
    Project     = var.project_name
    ManagedBy   = "Terraform"
  }
}

# Create Subnet
resource "azurerm_subnet" "web" {
  name                 = "${var.project_name}-subnet-web-${var.environment}"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.1.0/24"]
}

outputs.tf – Return values:

output "resource_group_name" {
  description = "Name of the created resource group"
  value       = azurerm_resource_group.main.name
}

output "virtual_network_id" {
  description = "ID of the created virtual network"
  value       = azurerm_virtual_network.main.id
}

output "subnet_id" {
  description = "ID of the created subnet"
  value       = azurerm_subnet.web.id
}

Deploying Your First Resources

Initialize and deploy your Terraform configuration:

# Initialize Terraform (downloads providers)
terraform init

# Validate configuration syntax
terraform validate

# Preview changes
terraform plan

# Apply changes (create resources)
terraform apply

When prompted, type yes to confirm the deployment. Terraform will create your resource group, virtual network, and subnet in Azure.

Building a Complete Web Application Stack

Now let's extend our terraform azure tutorial to include a realistic web application infrastructure with load balancer, virtual machines, and database.

Add to main.tf:

# Create Network Security Group
resource "azurerm_network_security_group" "web" {
  name                = "${var.project_name}-nsg-web-${var.environment}"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "HTTP"
    priority                   = 1001
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "80"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "HTTPS"
    priority                   = 1002
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "443"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  tags = {
    Environment = var.environment
    Project     = var.project_name
    ManagedBy   = "Terraform"
  }
}

# Associate NSG with Subnet
resource "azurerm_subnet_network_security_group_association" "web" {
  subnet_id                 = azurerm_subnet.web.id
  network_security_group_id = azurerm_network_security_group.web.id
}

# Create Public IP for Load Balancer
resource "azurerm_public_ip" "lb" {
  name                = "${var.project_name}-pip-lb-${var.environment}"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  allocation_method   = "Static"
  sku                 = "Standard"

  tags = {
    Environment = var.environment
    Project     = var.project_name
    ManagedBy   = "Terraform"
  }
}

# Create Load Balancer
resource "azurerm_lb" "main" {
  name                = "${var.project_name}-lb-${var.environment}"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  sku                 = "Standard"

  frontend_ip_configuration {
    name                 = "PublicIPAddress"
    public_ip_address_id = azurerm_public_ip.lb.id
  }

  tags = {
    Environment = var.environment
    Project     = var.project_name
    ManagedBy   = "Terraform"
  }
}

# Create MySQL Flexible Server
resource "azurerm_mysql_flexible_server" "main" {
  name                = "${var.project_name}-mysql-${var.environment}"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  administrator_login = "mysqladmin"
  sku_name            = "B_Standard_B1s"
  version             = "8.0.21"

  # Never a literal. Supplied at plan time via TF_VAR_mysql_admin_password.
  administrator_password = var.mysql_admin_password

  storage {
    size_gb = 20
  }

  tags = {
    Environment = var.environment
    Project     = var.project_name
    ManagedBy   = "Terraform"
  }
}

If the password already lives in Key Vault — which is where it belongs once you are past the demo stage — swap the variable for an azurerm_key_vault_secret data source and keep it out of your shell history entirely:

data "azurerm_key_vault" "main" {
  name                = "kv-tfdemo-shared"
  resource_group_name = "rg-shared-secrets"
}

data "azurerm_key_vault_secret" "mysql_admin" {
  name         = "mysql-admin-password"
  key_vault_id = data.azurerm_key_vault.main.id
}

# ...then in azurerm_mysql_flexible_server.main:
# administrator_password = data.azurerm_key_vault_secret.mysql_admin.value

Either way the value still lands in state, which is the real reason the state file belongs in encrypted, access-controlled remote storage — the subject of the next section.

Implementing Remote State Management

Creating Azure Storage for State

Remote state storage prevents conflicts in team environments and provides backup for your Terraform state. The azurerm backend stores state in Azure Blob Storage and supports state locking natively via blob leases:

# Create storage account for Terraform state
az group create --name "rg-terraform-state" --location "East US"

az storage account create \
  --name "tfstate$RANDOM" \
  --resource-group "rg-terraform-state" \
  --location "East US" \
  --sku "Standard_LRS" \
  --encryption-services blob

# Get storage account key
ACCOUNT_KEY=$(az storage account keys list \
  --resource-group "rg-terraform-state" \
  --account-name "tfstate$RANDOM" \
  --query '[0].value' -o tsv)

# Create container for state files
az storage container create \
  --name "terraform-state" \
  --account-name "tfstate$RANDOM" \
  --account-key $ACCOUNT_KEY

Configuring Backend

Update terraform.tf to use remote state:

terraform {
  required_version = ">= 1.6.0"
  
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "tfstateYOUR_RANDOM_NUMBER"
    container_name       = "terraform-state"
    key                  = "dev.terraform.tfstate"
  }
  
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.0"
    }
  }
}

Migrate existing state to remote backend:

terraform init -migrate-state

Troubleshooting Common Issues

Authentication Failures

Error: Error: building AzureRM Client: Authenticating using the Azure CLI is only supported as a User

Fix: Ensure service principal environment variables are set correctly:

# Verify variables are set
echo $ARM_CLIENT_ID
echo $ARM_TENANT_ID

# Re-export if empty
export ARM_CLIENT_ID="your-app-id"
export ARM_CLIENT_SECRET="your-password"

Resource Naming Conflicts

Error: Error: A resource with the ID already exists

Fix: Use unique naming patterns with random suffixes:

resource "random_id" "suffix" {
  byte_length = 4
}

resource "azurerm_storage_account" "example" {
  name = "${var.project_name}sa${random_id.suffix.hex}"
  # ... other configuration
}

State Lock Issues

Error: Error: Error acquiring the state lock

Fix: Break the lock if no other Terraform processes are running:

terraform force-unlock LOCK_ID

Provider Version Conflicts

Error: Error: Failed to query available provider packages

Fix: Clear provider cache and re-initialize:

rm -rf .terraform
rm .terraform.lock.hcl
terraform init

Common Mistakes to Avoid

Hardcoding Secrets: Never put passwords or keys directly in Terraform files. A literal in a .tf file is a literal in Git history forever, and no amount of later rotation removes it from the commits. The MySQL server earlier in this guide takes its password from a variable marked sensitive for exactly this reason — that flag keeps the value out of plan and apply output, and sourcing it from Key Vault keeps it out of your shell history too:

# BAD
administrator_password = "P@ssw0rd123!"

# GOOD — sensitive variable, supplied via TF_VAR_mysql_admin_password
administrator_password = var.mysql_admin_password

# BETTER — resolved from Key Vault at plan time
administrator_password = data.azurerm_key_vault_secret.mysql_admin.value

Missing Resource Tags: Always tag resources for cost tracking and governance:

tags = {
  Environment = var.environment
  Project     = var.project_name
  ManagedBy   = "Terraform"
  CostCenter  = var.cost_center
}

Ignoring State Files: Never commit .tfstate files to version control or delete them manually.

Skipping Plan Reviews: Always run terraform plan and review changes before applying in production.

Overly Complex Configurations: Break large configurations into modules for better maintainability.

Key Takeaways

  • Service principal authentication provides secure, automated access for Terraform to manage Azure resources without human intervention
  • Remote state storage in Azure Blob Storage prevents team conflicts and provides disaster recovery for infrastructure configurations
  • Consistent resource naming and tagging strategies make multi-environment deployments manageable and cost-transparent
  • Terraform plan reviews catch potential issues before they impact production environments and help teams collaborate effectively
  • Modular configuration patterns keep complex Azure infrastructure maintainable as your cloud footprint scales
  • Environment variable management keeps sensitive credentials out of version control while maintaining deployment automation
  • State lock mechanisms prevent concurrent modifications that could corrupt infrastructure state and cause deployment failures

Next Steps

  1. Explore Terraform Modules: Learn to create reusable infrastructure components with HashiCorp's module registry
  2. Implement CI/CD Pipelines: Integrate Terraform with Azure DevOps or GitHub Actions for automated deployments
  3. Study Advanced State Management: Investigate workspace management and environment separation strategies
  4. Security Hardening: Implement Azure Key Vault integration and infrastructure scanning with tools like Checkov
  5. Cost Optimization: Set up Azure Cost Management integration and implement resource lifecycle policies

Related Articles

Thorsteinn Halldorsson Senior Cloud Engineer

Senior Cloud Engineer with 25+ years of hands-on experience across the datacenter-to-cloud stack: fiber SAN and disk storage, IBM/Lenovo blade and Dell/HP/Lenovo servers, Hyper-V and VMware clusters, and SQL and Remote Desktop Services (RDS) clusters. Deep in the Microsoft platform — Active Directory, PKI/certificate services, SQL, Power BI, Dynamics 365 Business Central (NAV) and AX (Axapta), Microsoft 365, Entra, and Intune — with a focus on Azure operations, FinOps, and applying AI tools like GitHub Copilot and Claude in real workflows. Writes practical, no-nonsense guides for IT professionals who need to ship real solutions.

Leave a Reply

Your email address will not be published. Required fields are marked *