Terraform on Azure — Modules, State, and CI/CD Integration
Bicep is Azure-native. Terraform is cloud-agnostic. If your organization lives entirely in Azure, Bicep is a strong choice. But the moment you manage resources across AWS, GCP, or even GitHub repositories, Terraform becomes the lingua franca that your platform team actually standardizes on. This post covers everything you need to run Terraform on Azure in production — authentication, core resources, remote state, modules, and CI/CD pipelines that plan on pull requests and apply on merge.
AzureRM Provider Setup
Every Terraform project targeting Azure starts with the AzureRM provider. Pin the version to avoid surprise breaking changes.
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.85.0"
}
}
}
provider "azurerm" {
features {
resource_group {
prevent_deletion_if_contains_resources = true
}
key_vault {
purge_soft_delete_on_destroy = false
}
}
}
Authentication Methods
Terraform needs credentials to talk to the Azure Resource Manager API. Choose the method that fits your environment.
| Method | Best For | Security Level |
|---|---|---|
| Azure CLI | Local development | Low (user token) |
| Service Principal + Secret | CI/CD pipelines | Medium |
| Service Principal + Certificate | High-security pipelines | High |
| Managed Identity | Azure-hosted runners (VMs, ACI) | Highest |
| OIDC (Workload Identity) | GitHub Actions, GitLab CI | Highest |
Service Principal Authentication
# Create a service principal for Terraform
az ad sp create-for-rbac \
--name "sp-terraform-prod" \
--role "Contributor" \
--scopes "/subscriptions/<subscription-id>" \
--years 1
# Output:
# {
# "appId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
# "password": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
# "tenant": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# }
Export the credentials as environment variables so the provider picks them up automatically:
export ARM_CLIENT_ID="<appId>"
export ARM_CLIENT_SECRET="<password>"
export ARM_SUBSCRIPTION_ID="<subscription-id>"
export ARM_TENANT_ID="<tenant>"
# Verify authentication
terraform plan
Managed Identity Authentication
When your CI/CD runner lives inside Azure (a VM, Container Instance, or AKS pod), use managed identity and skip secrets entirely.
provider "azurerm" {
features {}
use_msi = true
subscription_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
Common Azure Resources in Terraform
Here is a complete configuration that provisions a resource group, VNet, subnet, NSG, and a Linux VM:
resource "azurerm_resource_group" "main" {
name = "rg-webapp-prod-eastus"
location = "eastus"
tags = {
Environment = "Production"
Team = "Platform"
}
}
resource "azurerm_virtual_network" "main" {
name = "vnet-webapp-prod"
address_space = ["10.10.0.0/16"]
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
}
resource "azurerm_subnet" "app" {
name = "snet-app"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.10.1.0/24"]
}
resource "azurerm_network_security_group" "app" {
name = "nsg-app"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
security_rule {
name = "AllowSSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "10.0.0.0/8"
destination_address_prefix = "*"
}
}
resource "azurerm_linux_virtual_machine" "app" {
name = "vm-webapp-01"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = "Standard_B2s"
admin_username = "azureadmin"
network_interface_ids = [azurerm_network_interface.app.id]
admin_ssh_key {
username = "azureadmin"
public_key = file("~/.ssh/id_rsa.pub")
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
}
Remote State in Azure Storage
Never store Terraform state locally in a team environment. Use an Azure Storage Account with locking.
# Create storage for Terraform state
az group create --name rg-terraform-state --location eastus
az storage account create \
--name sttfstatecontoso \
--resource-group rg-terraform-state \
--sku Standard_ZRS \
--encryption-services blob \
--min-tls-version TLS1_2 \
--allow-blob-public-access false
az storage container create \
--name tfstate \
--account-name sttfstatecontoso
# Enable versioning for state recovery
az storage account blob-service-properties update \
--account-name sttfstatecontoso \
--enable-versioning true
Configure the backend in your Terraform configuration:
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "sttfstatecontoso"
container_name = "tfstate"
key = "webapp/prod/terraform.tfstate"
}
}
Azure DevOps Pipeline for Terraform
This pipeline runs terraform plan on pull requests and terraform apply on merges to main.
# azure-pipelines.yml
trigger:
branches:
include: [main]
pr:
branches:
include: [main]
pool:
vmImage: 'ubuntu-latest'
variables:
- group: terraform-credentials # Contains ARM_CLIENT_ID, ARM_CLIENT_SECRET, etc.
- name: tfWorkingDir
value: '$(System.DefaultWorkingDirectory)/infra'
stages:
- stage: Plan
displayName: 'Terraform Plan'
jobs:
- job: TerraformPlan
steps:
- task: TerraformInstaller@1
inputs:
terraformVersion: '1.7.x'
- script: terraform init -backend-config="key=webapp/prod/terraform.tfstate"
workingDirectory: $(tfWorkingDir)
displayName: 'Terraform Init'
env:
ARM_CLIENT_ID: $(ARM_CLIENT_ID)
ARM_CLIENT_SECRET: $(ARM_CLIENT_SECRET)
ARM_SUBSCRIPTION_ID: $(ARM_SUBSCRIPTION_ID)
ARM_TENANT_ID: $(ARM_TENANT_ID)
- script: terraform plan -out=tfplan -input=false
workingDirectory: $(tfWorkingDir)
displayName: 'Terraform Plan'
env:
ARM_CLIENT_ID: $(ARM_CLIENT_ID)
ARM_CLIENT_SECRET: $(ARM_CLIENT_SECRET)
ARM_SUBSCRIPTION_ID: $(ARM_SUBSCRIPTION_ID)
ARM_TENANT_ID: $(ARM_TENANT_ID)
- publish: $(tfWorkingDir)/tfplan
artifact: tfplan
displayName: 'Publish Plan Artifact'
- stage: Apply
displayName: 'Terraform Apply'
dependsOn: Plan
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: TerraformApply
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: tfplan
- script: |
terraform init
terraform apply -auto-approve $(Pipeline.Workspace)/tfplan/tfplan
workingDirectory: $(tfWorkingDir)
displayName: 'Terraform Apply'
env:
ARM_CLIENT_ID: $(ARM_CLIENT_ID)
ARM_CLIENT_SECRET: $(ARM_CLIENT_SECRET)
ARM_SUBSCRIPTION_ID: $(ARM_SUBSCRIPTION_ID)
ARM_TENANT_ID: $(ARM_TENANT_ID)
GitHub Actions for Terraform on Azure
# .github/workflows/terraform.yml
name: Terraform Azure
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read
pull-requests: write
jobs:
terraform:
runs-on: ubuntu-latest
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_USE_OIDC: true
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.x
- name: Terraform Init
run: terraform init
working-directory: infra
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
working-directory: infra
- name: Comment Plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const plan = `${{ steps.plan.outputs.stdout }}`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Terraform Plan\n\`\`\`\n${plan.substring(0, 60000)}\n\`\`\``
});
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve tfplan
working-directory: infra
Terraform vs Bicep Comparison
| Feature | Terraform | Bicep |
|---|---|---|
| Multi-cloud | AWS, Azure, GCP, 3000+ providers | Azure only |
| State management | Required (remote backend) | Handled by ARM (no state file) |
| Language | HCL | Bicep DSL |
| Modules | Terraform Registry + private | Bicep Registry + private |
| Plan/Preview | terraform plan (detailed) | az deployment what-if |
| Learning curve | Moderate | Lower (Azure devs) |
| Drift detection | terraform plan shows drift | Limited |
| Community modules | Massive ecosystem | Growing |
| CI/CD integration | Universal | Best with Azure DevOps |
| Destroy support | terraform destroy | Manual deletion |
When to use Terraform: Multi-cloud environments, teams already using Terraform for other providers, need for state-based drift detection, complex dependency graphs.
When to use Bicep: Azure-only shops, teams embedded in the Microsoft ecosystem, desire to avoid state file management, rapid prototyping with Azure-specific constructs.
There is no wrong answer here. Pick the tool that matches your team's existing skills and multi-cloud reality. If you are already using Terraform for AWS, adding Azure to that same workflow is trivial. If you live and breathe Azure, Bicep gives you a shorter path to production. Either way, infrastructure as code beats clicking through the portal every single time.
