Skip to content
· 3 min read

Terraform Module Design Patterns for Production

Practical patterns for designing reusable, composable Terraform modules that scale from dev to production environments.

Terraform IaC AWS Azure DevOps

After writing 16+ production Terraform modules that provision everything from AKS clusters to WAF policies, here are the patterns that have proven most valuable.

1. Layered State Architecture

Don’t put everything in one Terraform state file. Instead, layer your infrastructure:

base/       → Networking, storage, databases, identity providers
identity/   → Managed identities, role assignments, federated credentials
compute/    → Kubernetes clusters, container registries, CDNs
gitops/     → GitOps operator bootstrap, repository sources

Each layer references the previous via terraform_remote_state:

data "terraform_remote_state" "base" {
  backend = "azurerm"
  config = {
    resource_group_name  = "terraform-state"
    storage_account_name = "tfstate"
    container_name       = "state"
    key                  = "base.tfstate"
  }
}

# Use outputs from the base layer
module "aks" {
  source    = "../../modules/aks"
  vnet_id   = data.terraform_remote_state.base.outputs.vnet_id
  subnet_id = data.terraform_remote_state.base.outputs.aks_subnet_id
}

Why? A single state file for your entire infrastructure means every terraform plan takes forever, blast radius is massive, and state locking blocks your entire team.

2. Module Interface Design

Every module should have a clear, well-typed interface. Use variable validation blocks and descriptive types:

variable "cluster_config" {
  type = object({
    name                = string
    kubernetes_version  = string
    node_count         = number
    vm_size            = string
    enable_spot_pools  = optional(bool, false)
  })

  validation {
    condition     = can(regex("^1\\.(2[8-9]|3[0-9])$", var.cluster_config.kubernetes_version))
    error_message = "Kubernetes version must be 1.28 or higher."
  }
}

3. Zero Static Credentials

Never store credentials in Terraform state or variables. Instead:

  • Azure: Use Managed Identities and Workload Identity Federation
  • AWS: Use IAM Roles for Service Accounts (IRSA)
  • CI/CD: Use OIDC federation with your pipeline provider
# Federated credential for Azure DevOps pipeline
resource "azurerm_federated_identity_credential" "pipeline" {
  name                = "azure-devops-oidc"
  resource_group_name = var.resource_group_name
  parent_id           = azurerm_user_assigned_identity.pipeline.id
  audience            = ["api://AzureADTokenExchange"]
  issuer              = "https://vstoken.dev.azure.com/${var.ado_org_id}"
  subject             = "sc://${var.ado_org}/${var.ado_project}/${var.service_connection_name}"
}

4. Consistent Tagging and Naming

Create a locals block in every root module that generates consistent resource names and tags:

locals {
  name_prefix = "${var.project}-${var.environment}"
  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
    Module      = basename(path.module)
  }
}

5. Output Everything Other Layers Need

Think about your module outputs as an API. Every resource ID, endpoint, or connection string that another layer might need should be an output:

output "cluster_id" {
  value       = azurerm_kubernetes_cluster.main.id
  description = "The AKS cluster resource ID"
}

output "oidc_issuer_url" {
  value       = azurerm_kubernetes_cluster.main.oidc_issuer_url
  description = "The OIDC issuer URL for workload identity federation"
}

These patterns have allowed me to manage complex, multi-service production infrastructure with confidence and reproducibility. The key insight: treat your Terraform code with the same rigor as application code.