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