DEV Community

Cover image for Terraform Azure Automation: Enterprise Infrastructure as Code with GitHub Actions
DevStackHub
DevStackHub

Posted on Originally published at devstackhub.tech

Terraform Azure Automation: Enterprise Infrastructure as Code with GitHub Actions

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  |
                                                                        +---------------------+
Enter fullscreen mode Exit fullscreen mode

📂 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
Enter fullscreen mode Exit fullscreen mode

🔐 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"
}
Enter fullscreen mode Exit fullscreen mode

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."
}
Enter fullscreen mode Exit fullscreen mode

Initialize and apply the bootstrap state:

cd backend-bootstrap
terraform init
terraform apply -auto-approve
Enter fullscreen mode Exit fullscreen mode

🌐 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"]
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

modules/networking/outputs.tf

output "vnet_id" {
  value = azurerm_virtual_network.vnet.id
}

output "app_subnet_id" {
  value = azurerm_subnet.app_subnet.id
}
Enter fullscreen mode Exit fullscreen mode

📦 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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

⚙️ 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
}
Enter fullscreen mode Exit fullscreen mode

environments/prod/outputs.tf

output "application_url" {
  value       = "https://${module.container_app.fqdn}"
  description = "Public application endpoint."
}
Enter fullscreen mode Exit fullscreen mode

🚀 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
Enter fullscreen mode Exit fullscreen mode
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)