Terraform Module Design Patterns for Production
Practical patterns for designing reusable, composable Terraform modules that scale from dev to production environments.
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.