Terraform Implementation Guide¶
Related: README | GitHub Actions CI/CD | EA & Subscription Architecture
Overview¶
This guide describes the Terraform implementation strategy for deploying Azure Landing Zones using the Azure Verified Modules and ALZ Terraform Accelerator. The implementation is optimized for a startup platform team managing a multi-region SaaS application.
Implementation Approach¶
Azure Landing Zones Terraform Options¶
| Option | Description | Recommendation |
|---|---|---|
| ALZ Terraform Accelerator | Automated bootstrap with CI/CD setup | Recommended for Day 1 |
| Azure Verified Modules | Modular approach for customization | Use after initial setup |
| Legacy CAF Module | Older enterprise-scale module | Not recommended for new deployments |
Why ALZ Terraform Accelerator?¶
graph LR
subgraph "Traditional Approach"
T1[Manual Setup] --> T2[Custom Modules]
T2 --> T3[Build CI/CD]
T3 --> T4[Deploy ALZ]
end
subgraph "Accelerator Approach"
A1[Run Accelerator] --> A2[Bootstrap Complete]
A2 --> A3[CI/CD Ready]
A3 --> A4[Deploy ALZ]
end
style A1 fill:#c8e6c9
style A2 fill:#c8e6c9
style A3 fill:#c8e6c9
The accelerator provides: - Pre-configured GitHub Actions workflows - OIDC/Workload Identity Federation setup - Terraform state backend configuration - Management group and policy deployment - Reduced time-to-value for platform teams
Repository Structure¶
Recommended Directory Layout¶
platform-landing-zones/
├── .github/
│ └── workflows/
│ ├── terraform-plan.yml
│ ├── terraform-apply.yml
│ └── terraform-drift.yml
├── bootstrap/
│ └── README.md # Accelerator bootstrap docs
├── platform/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── providers.tf
│ ├── terraform.tf
│ ├── locals.tf
│ └── config/
│ ├── management_groups.yaml
│ ├── policy_assignments.yaml
│ └── connectivity.yaml
├── modules/
│ └── subscription-vending/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── environments/
│ ├── prod.tfvars
│ └── dev.tfvars
└── README.md
Multi-Region vs. Single Deployment¶
| Approach | Pros | Cons | Recommendation |
|---|---|---|---|
| Single deployment | Simpler state, easier correlation | Blast radius, slower applies | Startup scale |
| Per-region deployment | Isolation, parallel execution | Complex orchestration | Enterprise scale |
| Per-component deployment | Maximum isolation | Dependency management | Not recommended |
Recommendation: Start with a single deployment for the platform landing zones, with regional resources managed within the same state. As scale increases, consider splitting.
Terraform State Management¶
State Backend Configuration¶
# terraform.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
azuread = {
source = "hashicorp/azuread"
version = "~> 3.0"
}
}
backend "azurerm" {
# Values provided via -backend-config or environment variables
# resource_group_name = "rg-terraform-state"
# storage_account_name = "stterraformstate001"
# container_name = "tfstate"
# key = "platform.tfstate"
use_oidc = true
use_azuread_auth = true
}
}
State Storage Architecture¶
graph TB
subgraph "Management Subscription"
RG[rg-terraform-state]
subgraph "Storage Account"
SA[stterraformstate001<br/>Geo-Redundant]
subgraph "Containers"
C1[tfstate-platform]
C2[tfstate-connectivity]
C3[tfstate-applications]
end
end
SA --> C1
SA --> C2
SA --> C3
end
subgraph "State Files"
S1[platform.tfstate]
S2[connectivity-eastus2.tfstate]
S3[app-saas-prod.tfstate]
end
C1 --> S1
C2 --> S2
C3 --> S3
style SA fill:#e3f2fd
State Backend Storage Account¶
state_storage_account:
name: "stterraformstate001"
resource_group: "rg-terraform-state"
location: "eastus2"
account_tier: "Standard"
account_replication_type: "GRS" # Geo-redundant
# Security settings
public_network_access_enabled: false # Private endpoint only
allow_nested_items_to_be_public: false
shared_access_key_enabled: false # Use Entra ID auth
# Enable versioning for state recovery
blob_properties:
versioning_enabled: true
delete_retention_policy:
days: 30
# Network rules (after private endpoint setup)
network_rules:
default_action: "Deny"
bypass: ["AzureServices"]
ip_rules: [] # No public access
virtual_network_subnet_ids: []
# Private endpoint
private_endpoint:
name: "pe-tfstate-blob"
subnet_id: "/subscriptions/.../snet-privateendpoints"
private_dns_zone_ids:
- "/subscriptions/.../privateDnsZones/privatelink.blob.core.windows.net"
State Locking¶
State locking is automatic with Azure Blob Storage backend. The lease mechanism prevents concurrent modifications.
state_locking:
mechanism: "Azure Blob Lease"
timeout: "Infinite until released"
troubleshooting:
# If state is locked after failed run
command: "terraform force-unlock LOCK_ID"
warning: "Only use if you're certain no other operation is running"
ALZ Module Configuration¶
Platform Landing Zone Module¶
# platform/main.tf
module "alz" {
source = "Azure/avm-ptn-alz/azurerm"
version = "~> 0.10" # Use latest stable version
# Management group configuration
architecture_name = "saas"
location = var.primary_location
# Management group hierarchy
management_group_hierarchy = {
id = "saas-company"
displayName = "SaaS Company"
children = [
{
id = "saas-platform"
displayName = "Platform"
children = [
{ id = "saas-platform-identity", displayName = "Identity" },
{ id = "saas-platform-management", displayName = "Management" },
{ id = "saas-platform-connectivity", displayName = "Connectivity" }
]
},
{
id = "saas-landingzones"
displayName = "Landing Zones"
children = [
{ id = "saas-landingzones-prod", displayName = "Production" },
{ id = "saas-landingzones-nonprod", displayName = "Non-Production" }
]
},
{ id = "saas-sandbox", displayName = "Sandbox" },
{ id = "saas-decommissioned", displayName = "Decommissioned" }
]
}
# Policy configuration
policy_default_values = {
ama_change_tracking_data_collection_rule_id = ""
ama_mdfc_sql_data_collection_rule_id = ""
ama_vm_insights_data_collection_rule_id = ""
log_analytics_workspace_id = module.management.log_analytics_workspace_id
}
# Tags
default_tags = var.default_tags
}
Management Resources Module¶
# platform/management.tf
module "management" {
source = "./modules/management"
location = var.primary_location
resource_group_name = "rg-management-monitoring-${var.primary_location}"
log_analytics_workspace = {
name = "law-platform-${var.primary_location}-001"
sku = "PerGB2018"
retention_in_days = 90
daily_quota_gb = 10
}
defender_for_cloud = {
tier = "Standard"
plans = [
"AppServices",
"ContainerRegistry",
"Containers",
"KeyVaults",
"SqlServers",
"SqlServerVirtualMachines",
"StorageAccounts",
"VirtualMachines",
"Arm",
"Dns"
]
}
tags = var.default_tags
}
Connectivity Resources Module¶
# platform/connectivity.tf
module "connectivity" {
source = "./modules/connectivity"
for_each = var.regions
location = each.value.location
resource_group_name = "rg-connectivity-hub-${each.value.location}"
hub_network = {
name = "vnet-hub-${each.value.location}-001"
address_space = each.value.hub_address_space
subnets = {
AzureFirewallSubnet = {
address_prefix = cidrsubnet(each.value.hub_address_space[0], 10, 0)
}
AzureBastionSubnet = {
address_prefix = cidrsubnet(each.value.hub_address_space[0], 10, 1)
}
private_endpoints = {
address_prefix = cidrsubnet(each.value.hub_address_space[0], 8, 10)
private_endpoint_network_policies = "Disabled"
}
}
}
# Day 2: Azure Firewall
deploy_firewall = var.deploy_firewall
firewall_sku = "Standard"
# Azure Bastion
deploy_bastion = true
bastion_sku = "Standard"
# Private DNS Zones
private_dns_zones = [
"privatelink.blob.core.windows.net",
"privatelink.database.windows.net",
"privatelink.vaultcore.azure.net",
"privatelink.azurecr.io",
"privatelink.documents.azure.com"
]
tags = var.default_tags
}
Variables Configuration¶
Input Variables¶
# platform/variables.tf
variable "primary_location" {
description = "Primary Azure region"
type = string
default = "eastus2"
}
variable "secondary_location" {
description = "Secondary Azure region for DR"
type = string
default = "westus2"
}
variable "regions" {
description = "Map of regions for multi-region deployment"
type = map(object({
location = string
hub_address_space = list(string)
is_primary = bool
}))
default = {
eastus2 = {
location = "eastus2"
hub_address_space = ["10.0.0.0/16"]
is_primary = true
}
westus2 = {
location = "westus2"
hub_address_space = ["10.100.0.0/16"]
is_primary = false
}
}
}
variable "deploy_firewall" {
description = "Deploy Azure Firewall (Day 2)"
type = bool
default = false
}
variable "default_tags" {
description = "Default tags for all resources"
type = map(string)
default = {
Environment = "Platform"
ManagedBy = "Terraform"
CostCenter = "CC-PLATFORM"
DataClassification = "Internal"
}
}
variable "subscription_ids" {
description = "Subscription IDs for platform landing zones"
type = object({
identity = string
management = string
connectivity = string
})
}
Environment-Specific Values¶
# environments/prod.tfvars
primary_location = "eastus2"
secondary_location = "westus2"
deploy_firewall = true # Day 2 feature
subscription_ids = {
identity = "00000000-0000-0000-0000-000000000001"
management = "00000000-0000-0000-0000-000000000002"
connectivity = "00000000-0000-0000-0000-000000000003"
}
default_tags = {
Environment = "Platform"
ManagedBy = "Terraform"
CostCenter = "CC-PLATFORM"
DataClassification = "Internal"
Owner = "platform-team"
}
Provider Configuration¶
Multi-Provider Setup¶
# platform/providers.tf
provider "azurerm" {
features {
resource_group {
prevent_deletion_if_contains_resources = true
}
key_vault {
purge_soft_delete_on_destroy = false
recover_soft_deleted_key_vaults = true
}
}
# OIDC authentication (no secrets)
use_oidc = true
# Default subscription
subscription_id = var.subscription_ids.management
# Skip provider registration if needed
skip_provider_registration = false
}
# Provider alias for connectivity subscription
provider "azurerm" {
alias = "connectivity"
subscription_id = var.subscription_ids.connectivity
use_oidc = true
features {}
}
# Provider alias for identity subscription
provider "azurerm" {
alias = "identity"
subscription_id = var.subscription_ids.identity
use_oidc = true
features {}
}
# Azure AD provider for identity resources
provider "azuread" {
use_oidc = true
}
Subscription Vending Module¶
Module Structure¶
# modules/subscription-vending/main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
variable "subscription_id" {
description = "Subscription ID to configure"
type = string
}
variable "management_group_id" {
description = "Target management group ID"
type = string
}
variable "workload_name" {
description = "Name of the workload"
type = string
}
variable "environment" {
description = "Environment (production, development, staging)"
type = string
}
variable "regions" {
description = "Regions to deploy spoke VNets"
type = list(string)
}
variable "hub_vnet_ids" {
description = "Map of region to hub VNet ID for peering"
type = map(string)
}
variable "spoke_address_spaces" {
description = "Map of region to spoke address space"
type = map(list(string))
}
variable "tags" {
description = "Tags for resources"
type = map(string)
}
# Move subscription to management group
resource "azurerm_management_group_subscription_association" "this" {
management_group_id = var.management_group_id
subscription_id = "/subscriptions/${var.subscription_id}"
}
# Create spoke VNets
resource "azurerm_resource_group" "network" {
for_each = toset(var.regions)
name = "rg-${var.workload_name}-network-${each.value}"
location = each.value
tags = var.tags
}
resource "azurerm_virtual_network" "spoke" {
for_each = toset(var.regions)
name = "vnet-spoke-${var.workload_name}-${each.value}-001"
location = each.value
resource_group_name = azurerm_resource_group.network[each.value].name
address_space = var.spoke_address_spaces[each.value]
tags = var.tags
}
# Peer to hub
resource "azurerm_virtual_network_peering" "spoke_to_hub" {
for_each = toset(var.regions)
name = "peer-to-hub-${each.value}"
resource_group_name = azurerm_resource_group.network[each.value].name
virtual_network_name = azurerm_virtual_network.spoke[each.value].name
remote_virtual_network_id = var.hub_vnet_ids[each.value]
allow_virtual_network_access = true
allow_forwarded_traffic = true
use_remote_gateways = false
}
# Configure diagnostic settings
resource "azurerm_monitor_diagnostic_setting" "activity_logs" {
name = "diag-activity-logs"
target_resource_id = "/subscriptions/${var.subscription_id}"
log_analytics_workspace_id = var.log_analytics_workspace_id
enabled_log {
category = "Administrative"
}
enabled_log {
category = "Security"
}
enabled_log {
category = "Alert"
}
}
output "spoke_vnet_ids" {
value = { for k, v in azurerm_virtual_network.spoke : k => v.id }
}
Local Development¶
Prerequisites¶
# Install Terraform
# macOS
brew install terraform
# Windows (via Chocolatey)
choco install terraform
# Verify installation
terraform --version
Authentication for Local Development¶
# Option 1: Azure CLI authentication
az login
az account set --subscription "sub-management-001"
# Option 2: Service Principal (for testing)
export ARM_CLIENT_ID="..."
export ARM_CLIENT_SECRET="..." # Only for local testing
export ARM_TENANT_ID="..."
export ARM_SUBSCRIPTION_ID="..."
Running Terraform Locally¶
# Initialize
terraform init \
-backend-config="resource_group_name=rg-terraform-state" \
-backend-config="storage_account_name=stterraformstate001" \
-backend-config="container_name=tfstate-platform" \
-backend-config="key=platform.tfstate"
# Plan
terraform plan -var-file="environments/prod.tfvars" -out=tfplan
# Apply (only in emergency - prefer CI/CD)
terraform apply tfplan
Import Existing Resources¶
If resources were created manually or via portal:
# Import management group
terraform import 'module.alz.azurerm_management_group.this["saas-company"]' "/providers/Microsoft.Management/managementGroups/saas-company"
# Import subscription association
terraform import 'azurerm_management_group_subscription_association.this' "/providers/Microsoft.Management/managementGroups/saas-platform-management/subscriptions/00000000-0000-0000-0000-000000000002"
# Import resource group
terraform import 'azurerm_resource_group.this' "/subscriptions/00000000-0000-0000-0000-000000000002/resourceGroups/rg-management-monitoring-eastus2"
Best Practices¶
Code Quality¶
| Practice | Description |
|---|---|
Use terraform fmt |
Consistent formatting |
Use terraform validate |
Syntax validation |
Use tflint |
Linting for best practices |
Use checkov |
Security scanning |
| Pin provider versions | Prevent breaking changes |
| Pin module versions | Reproducible deployments |
State Management¶
| Practice | Description |
|---|---|
| Remote state | Always use remote backend |
| State locking | Automatic with Azure Blob |
| State encryption | Azure handles at rest |
| State backup | Enable versioning on storage |
| Limit state access | RBAC on storage account |
Security¶
| Practice | Description |
|---|---|
| No secrets in code | Use OIDC or Key Vault |
| No secrets in state | Use sensitive = true |
| Review plans | Always review before apply |
| Use OIDC | No stored credentials |
| Audit access | Monitor state file access |
References¶
- Azure Landing Zones Terraform Accelerator
- Azure Verified Modules
- Terraform AzureRM Provider
- Terraform Backend Configuration
- Azure Landing Zones CAF Enterprise Scale
Previous: 04 - EA & Subscription Architecture | Next: 06 - GitHub Actions CI/CD