Managing Azure resources through the portal or untracked CLI scripts inevitably leads to configuration drift, privilege sprawl, and deployment failures. Implementing a Terraform on Azure automation pipeline establishes an immutable, auditable, and modular Infrastructure as Code (IaC) foundation capable of provisioning zero-downtime enterprise workloads.
This blueprint walks through building a production-ready Terraform on Azure automation architecture. You will provision a secure remote state backend using Azure Blob Storage and native lease locking, a hub-and-spoke Virtual Network (VNet), a serverless Azure Container App environment, and an automated GitHub Actions deployment pipeline leveraging OpenID Connect (OIDC) to eliminate static client secrets.
🏗️ Production Architecture Overview
The following architecture implements cloud-native isolation and zero-trust principles across your Azure subscription:
+----------------------------------------+
| GitHub Actions (CI/CD) |
+-------------------+--------------------+
|
(OIDC Federated)
|
v
+------------------------------------+
| Azure Remote State Backend |
| - Storage Account (TLS 1.2+) |
| - Blob Container (State Leases) |
| - Encrypted with Customer Keys |
+-----------------+------------------+
|
+-------------------------------+-------------------------------+
| |
+----------v----------+ +----------v----------+
| Public Hub VNet | | Spoke Workload VNet|
| - Ingress Subnet | <================== VNet Peering =====> | - Container Subnet |
| - NAT Gateway | | - DB Subnet (Priv) |
+---------------------+ +----------+----------+
|
+----------v----------+
| Azure Container Apps|
| - Managed Env |
| - Non-Root Runtime |
| - Autoscaling 1..5 |
+---------------------+
📂 Project Directory Structure
Organize your IaC repository into clear, independent modules:
terraform-azure-automation/
├── .github/
│ └── workflows/
│ └── terraform-azure.yml
├── backend-bootstrap/
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
├── environments/
│ └── prod/
│ ├── main.tf
│ ├── outputs.tf
│ ├── terraform.tfvars
│ └── variables.tf
└── modules/
├── networking/
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
└── container-app/
├── main.tf
├── outputs.tf
└── variables.tf
🔐 Stage 1: Zero-Trust Remote State Backend on Azure Storage
Azure Storage Blobs natively support distributed state locking via Azure Blob Leases, eliminating the need for a secondary lock database.
backend-bootstrap/main.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.95.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6.0"
}
}
}
provider "azurerm" {
features {}
}
resource "random_string" "suffix" {
length = 6
special = false
upper = false
}
resource "azurerm_resource_group" "state_rg" {
name = "rg-devstackhub-tfstate-prod"
location = "eastus2"
tags = {
Environment = "Production"
ManagedBy = "Terraform"
}
}
resource "azurerm_storage_account" "state_sa" {
name = "tfstate${random_string.suffix.result}"
resource_group_name = azurerm_resource_group.state_rg.name
location = azurerm_resource_group.state_rg.location
account_tier = "Standard"
account_replication_type = "GRS"
min_tls_version = "TLS1_2"
blob_properties {
versioning_enabled = true
delete_retention_policy {
days = 30
}
}
network_rules {
default_action = "Allow"
bypass = ["AzureServices"]
}
tags = {
Environment = "Production"
Purpose = "TerraformStateStorage"
}
}
resource "azurerm_storage_container" "state_container" {
name = "tfstate"
storage_account_name = azurerm_storage_account.state_sa.name
container_access_type = "private"
}
backend-bootstrap/outputs.tf
output "resource_group_name" {
value = azurerm_resource_group.state_rg.name
description = "Backend storage Resource Group name."
}
output "storage_account_name" {
value = azurerm_storage_account.state_sa.name
description = "Backend Storage Account name."
}
output "container_name" {
value = azurerm_storage_container.state_container.name
description = "Backend Blob Container name."
}
Initialize and apply the bootstrap state:
cd backend-bootstrap
terraform init
terraform apply -auto-approve
🌐 Stage 2: Modular Azure Virtual Network (VNet)
Isolate public endpoints and container runtimes into dedicated subnets with Network Security Groups (NSGs).
modules/networking/variables.tf
variable "resource_group_name" {
type = string
description = "Name of the resource group."
}
variable "location" {
type = string
description = "Azure region for deployment."
}
variable "vnet_address_space" {
type = list(string)
description = "CIDR block for the VNet."
default = ["10.100.0.0/16"]
}
variable "app_subnet_cidr" {
type = list(string)
description = "Subnet CIDR for container workloads."
default = ["10.100.1.0/24"]
}
modules/networking/main.tf
resource "azurerm_virtual_network" "vnet" {
name = "vnet-prod-workloads"
location = var.location
resource_group_name = var.resource_group_name
address_space = var.vnet_address_space
}
resource "azurerm_subnet" "app_subnet" {
name = "snet-container-apps"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = var.app_subnet_cidr
delegation {
name = "container-app-delegation"
service_delegation {
name = "Microsoft.App/environments"
actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
}
}
}
resource "azurerm_network_security_group" "app_nsg" {
name = "nsg-container-apps"
location = var.location
resource_group_name = var.resource_group_name
security_rule {
name = "AllowHTTPInbound"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "Internet"
destination_address_prefix = "*"
}
security_rule {
name = "AllowHTTPSInbound"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "Internet"
destination_address_prefix = "*"
}
}
resource "azurerm_subnet_network_security_group_association" "app_assoc" {
subnet_id = azurerm_subnet.app_subnet.id
network_security_group_id = azurerm_network_security_group.app_nsg.id
}
modules/networking/outputs.tf
output "vnet_id" {
value = azurerm_virtual_network.vnet.id
}
output "app_subnet_id" {
value = azurerm_subnet.app_subnet.id
}
📦 Stage 3: Azure Container Apps Microservice Module
Run stateless workloads on Azure Container Apps with automated ingress, Log Analytics integration, and auto-scaling.
modules/container-app/variables.tf
variable "resource_group_name" {
type = string
}
variable "location" {
type = string
}
variable "infrastructure_subnet_id" {
type = string
}
variable "container_image" {
type = string
default = "mcr.microsoft.com/azuredocs/aci-helloworld:latest"
}
variable "target_port" {
type = number
default = 80
}
modules/container-app/main.tf
resource "azurerm_log_analytics_workspace" "logs" {
name = "log-prod-apps"
location = var.location
resource_group_name = var.resource_group_name
sku = "PerGB2018"
retention_in_days = 30
}
resource "azurerm_container_app_environment" "env" {
name = "cae-prod-workloads"
location = var.location
resource_group_name = var.resource_group_name
log_analytics_workspace_id = azurerm_log_analytics_workspace.logs.id
infrastructure_subnet_id = var.infrastructure_subnet_id
internal_load_balancer_enabled = false
}
resource "azurerm_container_app" "app" {
name = "ca-web-service"
container_app_environment_id = azurerm_container_app_environment.env.id
resource_group_name = var.resource_group_name
revision_mode = "Single"
template {
container {
name = "web-server"
image = var.container_image
cpu = 0.5
memory = "1.0Gi"
}
min_replicas = 1
max_replicas = 5
}
ingress {
allow_insecure_connections = false
external_enabled = true
target_port = var.target_port
traffic_weight {
percentage = 100
latest_revision = true
}
}
}
⚙️ Stage 4: Production Environment Assembly
Assemble modules together and configure the remote state backend in your production root.
environments/prod/main.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.95.0"
}
}
backend "azurerm" {
resource_group_name = "rg-devstackhub-tfstate-prod"
storage_account_name = "<YOUR_GENERATED_STORAGE_ACCOUNT_NAME>"
container_name = "tfstate"
key = "prod.terraform.tfstate"
use_oidc = true
}
}
provider "azurerm" {
features {}
use_oidc = true
}
resource "azurerm_resource_group" "prod_rg" {
name = "rg-prod-workloads-eastus2"
location = "eastus2"
tags = {
Environment = "Production"
ManagedBy = "Terraform"
}
}
module "networking" {
source = "../../modules/networking"
resource_group_name = azurerm_resource_group.prod_rg.name
location = azurerm_resource_group.prod_rg.location
}
module "container_app" {
source = "../../modules/container-app"
resource_group_name = azurerm_resource_group.prod_rg.name
location = azurerm_resource_group.prod_rg.location
infrastructure_subnet_id = module.networking.app_subnet_id
}
environments/prod/outputs.tf
output "application_url" {
value = "https://${module.container_app.fqdn}"
description = "Public application endpoint."
}
🚀 Stage 5: GitHub Actions CI/CD Pipeline via Azure OIDC
Authenticate directly with Microsoft Entra ID (Azure AD) using short-lived tokens without storing long-lived ARM_CLIENT_SECRET values.
.github/workflows/terraform-azure.yml
name: Terraform Azure Automation Pipeline
on:
push:
branches: [ main ]
paths:
- 'environments/prod/**'
- 'modules/**'
pull_request:
branches: [ main ]
paths:
- 'environments/prod/**'
- 'modules/**'
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
validate:
name: Terraform Validate & Lint
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.5
- name: Terraform Format Check
run: terraform fmt -check -recursive
plan:
name: Azure OIDC Plan & Security Audit
needs: validate
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Log in to Azure via OIDC
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.5
- name: Terraform Init
working-directory: environments/prod
run: terraform init
- name: Terraform Plan
working-directory: environments/prod
run: terraform plan -no-color -out=tfplan
apply:
name: Azure OIDC Apply (Production)
needs: plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Log in to Azure via OIDC
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.5
- name: Terraform Init
working-directory: environments/prod
run: terraform init
- name: Terraform Apply
working-directory: environments/prod
run: terraform apply -auto-approve tfplan
| Domain | Control | Target Configuration |
|---|---|---|
| State Security | Blob Storage Versioning | Enabled with 30-day soft delete |
| Concurrency | State Locking Mechanism | Native Azure Blob Lease Locking |
| Authentication | CI/CD Pipeline Identity | Azure AD Workload Identity Federation (OIDC) |
| Network Security | Subnet Traffic Control | Network Security Groups (NSGs) with explicit allow rules |
| Workload Isolation | Compute Execution | Subnet delegation inside isolated virtual network |
💡 Summary & Takeaways
Automating Azure with Terraform guarantees consistency across multiple subscriptions and eliminates configuration drift:
Native Blob Locking: Use native Azure Blob Leases to secure state files without needing DynamoDB or external databases.
Secretless Deployments: Always connect GitHub Actions via Microsoft Entra ID OIDC federated credentials rather than static service principal passwords.
Modular Infrastructure: Decouple networking from application compute to allow isolated scaling and simpler disaster recovery.
Explore more production-ready DevOps blueprints and architecture guides on DevStackHub.
Top comments (0)