Why wrap resources in modules

Over the last few days I built three Terraform modules: Key Vault, Windows VM and Event Hub. Different resource types, but the same problems came up in all three.

If someone writes azurerm_key_vault directly, they have to remember to enable purge protection, set the network ACL default to deny, use RBAC instead of legacy access policies, set the minimum TLS version and configure diagnostic settings. Forget one and the vault works but doesn’t meet the security baseline.

Across several teams this leads to every vault being configured a bit differently, and nobody knows which ones are actually compliant. A module fixes that by encoding those decisions once. That only works if the module is opinionated. A thin wrapper around one resource doesn’t help.

Secure defaults

The main design decision was the same in all three modules: the secure option is the default, and the insecure one needs an explicit opt-in.

For the Key Vault module:

variable "public_network_access_enabled" {
type = bool
default = false
}
variable "rbac_authorization_enabled" {
type = bool
default = true
}
variable "purge_protection_enabled" {
type = bool
default = true
}
variable "soft_delete_retention_days" {
type = number
default = 90
}

The network ACL in the resource starts with deny-all:

network_acls {
default_action = "Deny"
bypass = var.network_acls_bypass
ip_rules = var.network_acls_ip_rules
virtual_network_subnet_ids = var.network_acls_virtual_network_subnet_ids
}

If someone needs public access, they set public_network_access_enabled = true, and that line shows up in the code review. The Windows VM module does the same with encryption_at_host_enabled = true and identity_type = "SystemAssigned", the Event Hub module with public_network_access_enabled = false and minimum_tls_version = "1.2".

Teams can still make exceptions. They just can’t make them by accident.

Input validation

Terraform’s validation blocks and precondition lifecycle rules are underused. Most modules I’ve seen either have no validation or only check things the Azure API would reject anyway. The useful checks catch logical errors before the plan runs.

In the Event Hub module, auto-inflate only works on the Standard SKU, and the maximum throughput units have to be at least the base capacity:

resource "azurerm_eventhub_namespace" "this" {
# ...
lifecycle {
precondition {
condition = !var.auto_inflate_enabled || var.sku == "Standard"
error_message = "auto_inflate_enabled can only be true when sku is 'Standard'."
}
precondition {
condition = !var.auto_inflate_enabled || var.maximum_throughput_units >= var.capacity
error_message = "maximum_throughput_units must be >= capacity when auto-inflate is enabled."
}
}
}

In the Key Vault module, RBAC and legacy access policies are mutually exclusive:

lifecycle {
precondition {
condition = !var.rbac_authorization_enabled || length(var.access_policies) == 0
error_message = "access_policies must be empty when rbac_authorization_enabled is true."
}
precondition {
condition = var.rbac_authorization_enabled || length(var.role_assignments) == 0
error_message = "role_assignments must be empty when rbac_authorization_enabled is false."
}
}

Without these checks you get an unclear Azure API error 20 minutes into an apply. With them you get a readable message at plan time.

Variable-level validation handles simpler things like naming rules. Key Vault names are strict:

variable "name" {
type = string
description = "The name of the Key Vault."
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,22}[a-z0-9]$", var.name))
error_message = "Name must be 3-24 characters, lowercase letters, digits, and hyphens."
}
}

Conditional resources with count and for_each

Not every deployment needs a private endpoint or diagnostic settings. I use count for single optional resources and for_each for collections.

Private endpoints look the same in all three modules:

resource "azurerm_private_endpoint" "this" {
count = var.private_endpoint != null ? 1 : 0
name = "pe-${var.name}"
location = var.location
resource_group_name = local.resource_group_name
subnet_id = var.private_endpoint.subnet_id
private_service_connection {
name = "psc-${var.name}"
private_connection_resource_id = azurerm_key_vault.this.id
subresource_names = ["vault"]
is_manual_connection = false
}
private_dns_zone_group {
name = "default"
private_dns_zone_ids = [var.private_endpoint.private_dns_zone_id]
}
}

var.private_endpoint is an object that defaults to null. If it’s null, nothing is created. If the caller passes subnet and DNS zone IDs, the module does the rest.

In the Event Hub module, the hubs themselves are a collection, so I use a map with for_each:

variable "event_hubs" {
type = map(object({
partition_count = optional(number, 4)
message_retention = optional(number, 7)
consumer_groups = optional(list(string), [])
authorization_rules = optional(list(object({
name = string
send = optional(bool, false)
listen = optional(bool, false)
manage = optional(bool, false)
})), [])
}))
default = {}
}

With optional() and defaults, callers only specify what differs. Two hubs with different settings:

event_hubs = {
"hub-ingest" = {
partition_count = 8
message_retention = 7
consumer_groups = ["processor", "analytics"]
authorization_rules = [
{ name = "sender", send = true },
{ name = "reader", listen = true }
]
}
"hub-telemetry" = {
partition_count = 4
consumer_groups = ["dashboard"]
}
}

Consumer groups and authorization rules are nested under each hub, and for_each can’t iterate nested structures directly. I flatten them in locals:

locals {
consumer_groups = {
for item in flatten([
for hub_name, hub in var.event_hubs : [
for cg_name in hub.consumer_groups : {
key = "${hub_name}-${cg_name}"
hub_name = hub_name
cg_name = cg_name
}
]
]) : item.key => item
}
}

That gives a flat map keyed by hub_name-cg_name. Authorization rules work the same way. It’s verbose, but the resource addresses stay stable when you add or remove hubs.

Where to draw the module boundary: the Windows VM

The VM module is the most complex of the three because a VM touches compute, storage, networking, identity, monitoring and optionally Active Directory. The question was what goes into the module and what the caller provides.

  • In the module: the VM, NICs (including multi-NIC), data disks, managed identity, domain join extension, Azure Monitor Agent, diagnostic settings and the data collection rule association.
  • Outside the module: subnet, NSG, Log Analytics workspace, data collection rules and availability set. The caller creates these and passes the IDs.

Network and monitoring infrastructure is shared between VMs. If the module created its own subnet or workspace, you’d get one per VM.

One VM-specific detail is the associate_default_nsg flag:

variable "associate_default_nsg" {
type = bool
default = false
description = "Associate the NSG with the default NIC."
}

network_security_group_id may not be known at plan time if the NSG is created in the same apply. Terraform needs count and for_each keys at plan time, so the decision whether to associate is a separate boolean from the NSG ID. Otherwise you get count depends on resource attributes that cannot be determined until apply.

Testing with Terratest

Each module has a Go test using Terratest that runs init, apply and a second apply as an idempotence check. The second apply is where problems show up: if Terraform wants to change something, the module doesn’t settle into a stable state.

func TestTerraformModule(t *testing.T) {
t.Parallel()
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: ".",
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApplyAndIdempotent(t, terraformOptions)
}

The test infrastructure (VNet, subnets, DNS zones, Log Analytics) lives in tests/main.tf next to the module call, so the test runs with all optional features enabled: private endpoints, diagnostic settings, RBAC and monitoring.

Tests run in GitHub Actions on every pull request. Azure authentication uses federated credentials (OpenID Connect), so there are no stored secrets in the pipeline. A run takes about 15-20 minutes per module, mostly waiting for Azure to create and delete resources.

Shared conventions

The three modules follow the same conventions:

  • Naming: pe-{name} for private endpoints, psc-{name} for private service connections, diag-{name} for diagnostic settings. In the portal you can see what created a resource.
  • File layout: main.tf for the primary resource, then network.tf, diagnostics.tf, rbac.tf. Variables and outputs in their own files.
  • Diagnostic settings: enabled with a boolean plus a workspace ID. A precondition checks that the workspace ID is set when diagnostics are on. The module decides which log categories to send.
  • Pre-commit hooks: the same .pre-commit-config.yaml everywhere, with terraform fmt, terraform-docs (generates the README), trivy (security scanning), tflint and commitizen for conventional commits.
  • Semantic versioning: Commitizen and semantic-release bump versions from commit messages. feat: for minor, fix: for patch, feat!: for major.

What I’d do differently

  1. Write the test first. Start with tests/main.tf and define how the module should be called. I did it the other way round and had to refactor the variable structure twice.
  2. Fewer optional features in v0.1. I put private endpoints, diagnostics and RBAC into the first version. Shipping the core resource first and adding features one by one would have been faster and easier to review.
  3. Explain defaults in variable descriptions. Mine describe what a variable does, not why the default is what it is. "Encryption at host requires feature registration on the subscription" is more useful than "Enable encryption at host.".

These modules are part of a wider landing zone setup. The cloud foundation guide covers the management group and subscription structure, the governance framework post covers how Azure Policy works alongside Terraform modules, and the EPAC series covers managing policy across landing zones.


Sources

  1. HashiCorp, “Module Development,” Terraform Documentation, https://developer.hashicorp.com/terraform/language/modules/develop

  2. HashiCorp, “Custom Validation Rules,” Terraform Documentation, https://developer.hashicorp.com/terraform/language/expressions/custom-conditions

  3. Gruntwork, “Terratest,” GitHub, https://github.com/gruntwork-io/terratest

  4. Microsoft, “Azure Key Vault Overview,” Azure Documentation, https://learn.microsoft.com/azure/key-vault/general/overview

  5. Microsoft, “Azure Event Hubs Overview,” Azure Documentation, https://learn.microsoft.com/azure/event-hubs/event-hubs-about

  6. Microsoft, “Windows Virtual Machines in Azure,” Azure Documentation, https://learn.microsoft.com/azure/virtual-machines/windows/overview