Skip to main content

Terraform on Azure — Modules, State, and CI/CD Integration

· 6 min read
Goel Academy
DevOps & Cloud Learning Hub

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.

MethodBest ForSecurity Level
Azure CLILocal developmentLow (user token)
Service Principal + SecretCI/CD pipelinesMedium
Service Principal + CertificateHigh-security pipelinesHigh
Managed IdentityAzure-hosted runners (VMs, ACI)Highest
OIDC (Workload Identity)GitHub Actions, GitLab CIHighest

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

FeatureTerraformBicep
Multi-cloudAWS, Azure, GCP, 3000+ providersAzure only
State managementRequired (remote backend)Handled by ARM (no state file)
LanguageHCLBicep DSL
ModulesTerraform Registry + privateBicep Registry + private
Plan/Previewterraform plan (detailed)az deployment what-if
Learning curveModerateLower (Azure devs)
Drift detectionterraform plan shows driftLimited
Community modulesMassive ecosystemGrowing
CI/CD integrationUniversalBest with Azure DevOps
Destroy supportterraform destroyManual 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.