The Problem with Copy-Paste Terraform
When you manage Azure infrastructure across dev, staging, and production, the temptation is to duplicate your Terraform files and tweak values. This works fine until you need to update something — then you're making the same change three times, and eventually the environments drift apart silently.
The solution is a proper module structure with environment-specific variable files. Here's the pattern I use.
Repository Layout
terraform-azure/
├── modules/
│ ├── aks/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── vnet/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── keyvault/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ │ ├── main.tf
│ │ └── terraform.tfvars
│ └── prod/
│ ├── main.tf
│ └── terraform.tfvars
└── backend.tf
The AKS Module
Each module exposes only what needs to vary between environments. Everything else — sensible defaults, tagging conventions, diagnostic settings — is baked in.
# modules/aks/variables.tf
variable "cluster_name" { type = string }
variable "resource_group" { type = string }
variable "location" { type = string }
variable "kubernetes_version" { type = string }
variable "node_count" { type = number; default = 2 }
variable "node_vm_size" { type = string; default = "Standard_D2s_v3" }
variable "environment" { type = string }
variable "vnet_subnet_id" { type = string }
variable "log_analytics_id" { type = string }
# modules/aks/main.tf
resource "azurerm_kubernetes_cluster" "this" {
name = var.cluster_name
location = var.location
resource_group_name = var.resource_group
kubernetes_version = var.kubernetes_version
dns_prefix = var.cluster_name
default_node_pool {
name = "system"
node_count = var.node_count
vm_size = var.node_vm_size
vnet_subnet_id = var.vnet_subnet_id
}
identity { type = "SystemAssigned" }
oms_agent {
log_analytics_workspace_id = var.log_analytics_id
}
tags = {
environment = var.environment
managed_by = "terraform"
}
}
Environment Composition
Each environment main.tf simply calls the modules with its own values. No logic lives here — just wiring.
# environments/prod/main.tf
module "vnet" {
source = "../../modules/vnet"
name = "vnet-prod"
resource_group = azurerm_resource_group.this.name
address_space = ["10.1.0.0/16"]
environment = "prod"
}
module "aks" {
source = "../../modules/aks"
cluster_name = "aks-prod"
resource_group = azurerm_resource_group.this.name
location = var.location
kubernetes_version = "1.29"
node_count = 5
node_vm_size = "Standard_D4s_v3"
vnet_subnet_id = module.vnet.aks_subnet_id
log_analytics_id = module.monitoring.workspace_id
environment = "prod"
}
Remote State with Azure Storage
Each environment has its own state file in Azure Blob Storage, isolated by container. This prevents accidental cross-environment state corruption.
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "tfstatenaveen"
container_name = "prod"
key = "prod.terraform.tfstate"
}
}
CI/CD Integration
GitHub Actions handles plan and apply. PRs trigger terraform plan with the output posted as a comment. Merges to main trigger terraform apply automatically after a required approval.
The discipline of keeping all logic inside modules and keeping environment files as pure composition is what prevents drift. If you're writing an if statement in an environment file, something has gone wrong.
Key Takeaways
- Modules own defaults and sensible conventions — environments just pass values
- Separate state per environment is non-negotiable for safety
- Tag every resource with
environmentandmanaged_by = "terraform"from day one - Version-pin your provider and module sources to avoid surprise upgrades