Every team starts by copy-pasting Terraform. One folder becomes two. Two become ten. Then someone fixes a bug in one copy and the other nine keep the bug. Modules exist to end that cycle. A module is a packaged, versioned, reusable unit of infrastructure. Done well, it is the difference between a team that ships gateways in an afternoon and a team that hand-builds each one.
This post builds two real modules: one for Azure API Management, one for AWS API Gateway. Along the way: module anatomy, the common-interface pattern for multi-cloud teams, versioning, composition, testing, and the anti-patterns to avoid.
Why modules
Three reasons.
First, DRY. An API gateway has dozens of settings: SKUs, policies, products, subscriptions, backends. Define them once in a module. Every team consumes the same hardened configuration.
Second, consistency. When security requires a new policy on every gateway, you change it in one place and roll it out by bumping a version pin. Without modules, you are editing ten folders and hoping you caught them all.
Third, onboarding. A new engineer does not need to learn every APIM setting to ship an API. They call the module with five variables and get a production-ready gateway.
Anatomy of a good module
A module is a folder with a contract. The contract has three parts.
Variables with validation. Every input declares its type, a description, and where it matters, a validation block. Validation catches bad input at plan time instead of apply time.
Outputs. A module that creates things but tells you nothing about them is a black box. Output the IDs, URLs, and names callers need.
README. Document the purpose, inputs, outputs, and one full example. If the README does not show a working example, the module is not done.
Standard layout:
modules/apim/
main.tf # resources
variables.tf # inputs with validation
outputs.tf # what callers get back
versions.tf # required providers
README.md # purpose, example
The versions.tf pins the provider so the module never silently picks up a breaking provider release:
terraform {
required_version = ">= 1.5"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">= 3.0, < 4.0"
}
}
}
Azure APIM module
Here is a module that provisions an API Management service and registers a list of APIs on it, each with a rate-limit policy and a product assignment.
variables.tf:
variable "name" {
type = string
description = "Name of the API Management service."
}
variable "location" {
type = string
description = "Azure region for the service."
}
variable "resource_group_name" {
type = string
description = "Resource group for the APIM service."
}
variable "sku_name" {
type = string
description = "APIM SKU."
default = "Developer_1"
validation {
condition = contains(["Developer_1", "Basic_1", "Standard_1", "Premium_1"], var.sku_name)
error_message = "SKU must be one of Developer_1, Basic_1, Standard_1, Premium_1."
}
}
variable "publisher_name" {
type = string
description = "Publisher name shown in the developer portal."
}
variable "publisher_email" {
type = string
description = "Publisher contact email."
validation {
condition = can(regex("^[^@]+@[^@]+\\.[^@]+$", var.publisher_email))
error_message = "Publisher email must be a valid email address."
}
}
variable "apis" {
type = list(object({
name = string
display_name = string
path = string
protocols = list(string)
backend_url = string
}))
description = "APIs to register on the gateway."
default = []
}
main.tf:
resource "azurerm_api_management" "this" {
name = var.name
location = var.location
resource_group_name = var.resource_group_name
publisher_name = var.publisher_name
publisher_email = var.publisher_email
sku_name = var.sku_name
identity {
type = "SystemAssigned"
}
}
resource "azurerm_api_management_api" "this" {
for_each = { for api in var.apis : api.name => api }
name = each.value.name
resource_group_name = var.resource_group_name
api_management_name = azurerm_api_management.this.name
revision = "1"
display_name = each.value.display_name
path = each.value.path
protocols = each.value.protocols
service_url = each.value.backend_url
}
resource "azurerm_api_management_api_policy" "rate_limit" {
for_each = azurerm_api_management_api.this
api_name = each.value.name
resource_group_name = var.resource_group_name
api_management_name = azurerm_api_management.this.name
xml_content = <<XML
<policies>
<inbound>
<rate-limit calls="100" renewal-period="60" />
<base />
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>
XML
}
resource "azurerm_api_management_product" "this" {
product_id = "default-product"
resource_group_name = var.resource_group_name
api_management_name = azurerm_api_management.this.name
display_name = "Default Product"
subscription_required = true
approval_required = false
published = true
}
resource "azurerm_api_management_product_api" "this" {
for_each = azurerm_api_management_api.this
api_name = each.value.name
product_id = azurerm_api_management_product.this.product_id
resource_group_name = var.resource_group_name
api_management_name = azurerm_api_management.this.name
}
outputs.tf:
output "gateway_url" {
description = "The gateway URL of the APIM service."
value = azurerm_api_management.this.gateway_url
}
output "service_id" {
description = "The resource ID of the APIM service."
value = azurerm_api_management.this.id
}
output "api_ids" {
description = "Map of API name to resource ID."
value = { for k, v in azurerm_api_management_api.this : k => v.id }
}
Notice the pattern: for_each over a map keyed by name. This keeps resource addresses stable when the list order changes. If you use count with a list, reordering the list destroys and recreates APIs. With for_each keyed by name, only real changes trigger updates.
AWS API Gateway module
Same idea, AWS REST API flavor. The module takes a list of endpoints and builds resources, methods, proxy integrations, a deployment, and a stage.
variables.tf:
variable "name" {
type = string
description = "Name of the REST API."
}
variable "description" {
type = string
description = "Description of the REST API."
default = "Managed by Terraform"
}
variable "stage_name" {
type = string
description = "Deployment stage name."
default = "prod"
validation {
condition = can(regex("^[a-z0-9-]+$", var.stage_name))
error_message = "Stage name must be lowercase alphanumeric with dashes."
}
}
variable "endpoints" {
type = list(object({
path_part = string
http_method = string
backend_uri = string
}))
description = "Endpoints to expose through the gateway."
default = []
validation {
condition = alltrue([
for e in var.endpoints : contains(["GET", "POST", "PUT", "PATCH", "DELETE"], e.http_method)
])
error_message = "HTTP methods must be one of GET, POST, PUT, PATCH, DELETE."
}
}
main.tf:
locals {
endpoints = { for e in var.endpoints : "${e.http_method}-${e.path_part}" => e }
}
resource "aws_api_gateway_rest_api" "this" {
name = var.name
description = var.description
endpoint_configuration {
types = ["REGIONAL"]
}
}
resource "aws_api_gateway_resource" "this" {
for_each = local.endpoints
rest_api_id = aws_api_gateway_rest_api.this.id
parent_id = aws_api_gateway_rest_api.this.root_resource_id
path_part = each.value.path_part
}
resource "aws_api_gateway_method" "this" {
for_each = local.endpoints
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.this[each.key].id
http_method = each.value.http_method
authorization = "NONE"
}
resource "aws_api_gateway_integration" "this" {
for_each = local.endpoints
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.this[each.key].id
http_method = aws_api_gateway_method.this[each.key].http_method
integration_http_method = "POST"
type = "HTTP_PROXY"
uri = each.value.backend_uri
}
resource "aws_api_gateway_deployment" "this" {
rest_api_id = aws_api_gateway_rest_api.this.id
triggers = {
redeploy = sha1(jsonencode([
aws_api_gateway_resource.this,
aws_api_gateway_method.this,
aws_api_gateway_integration.this,
]))
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_api_gateway_stage" "this" {
deployment_id = aws_api_gateway_deployment.this.id
rest_api_id = aws_api_gateway_rest_api.this.id
stage_name = var.stage_name
}
Two things worth noting. First, the triggers block with a SHA hash: API Gateway deployments are immutable snapshots. Without the trigger, changing a method does not create a new deployment and your change never goes live. The hash forces redeployment whenever the API configuration changes. This is the single most common gotcha with Terraform-managed API Gateway.
Second, create_before_destroy on the deployment. Without it, Terraform destroys the old deployment before creating the new one, and the stage briefly points at nothing. With it, the new deployment exists before the old one is removed.
outputs.tf:
output "invoke_url" {
description = "The invoke URL of the deployed stage."
value = aws_api_gateway_stage.this.invoke_url
}
output "rest_api_id" {
description = "The ID of the REST API."
value = aws_api_gateway_rest_api.this.id
}
output "execution_arn" {
description = "The execution ARN for IAM permissions."
value = aws_api_gateway_rest_api.this.execution_arn
}
The common-interface pattern
Here is the move that pays off for multi-cloud teams: give both modules the same variable names for the same concepts.
Both modules accept name. Both accept a list of backend definitions shaped as objects with a path, a method, and a backend URL. Callers learn one interface. When a team moves a workload from Azure to AWS, the module call changes but the shape of the inputs does not.
This does not mean the modules are identical inside. Cloud services differ, and each module should use its platform well. APIM gets products and XML policies. API Gateway gets deployments and stages. The interface is shared. The implementation is native.
Where the clouds genuinely diverge, let the variables diverge too. Do not force a fake common variable that maps to nothing on one side. Common where it is real, specific where it is not.
Versioned module sources
Never consume a module from a moving target. Pin to a Git tag:
module "apim" {
source = "git::https://github.com/myorg/terraform-modules.git//modules/apim?ref=v1.2.0"
name = "apim-prod"
location = "eastus"
resource_group_name = azurerm_resource_group.main.name
publisher_name = "Platform Team"
publisher_email = "platform@example.com"
apis = local.apis
}
The ?ref=v1.2.0 is the whole game. Without it, every terraform init can pull different code. With it, upgrades are deliberate: bump the ref, run plan, review the diff, apply.
Tag releases with semantic versioning. Breaking input changes bump the major version. New optional variables bump the minor. Fixes bump the patch. Write a short changelog per release so consumers know what changed before they bump.
Private registry option
If Git URLs get unwieldy across dozens of call sites, Terraform Cloud and Terraform Enterprise offer a private registry. Modules get versioned releases with a changelog UI, and the call site becomes:
module "apim" {
source = "app.terraform.io/myorg/apim/azurerm"
version = "1.2.0"
}
Cleaner to read, easier to browse. It costs money and ties you to the platform. For most teams, Git tags are enough until the module count gets large. Start with tags. Move to a registry when discovery becomes the problem.
Composition pattern
Modules compose. A root module for an environment calls child modules and wires outputs to inputs:
module "network" {
source = "git::https://github.com/myorg/terraform-modules.git//modules/network?ref=v2.0.1"
name = "vnet-prod"
location = "eastus"
resource_group_name = azurerm_resource_group.main.name
address_space = ["10.0.0.0/16"]
}
module "apim" {
source = "git::https://github.com/myorg/terraform-modules.git//modules/apim?ref=v1.2.0"
name = "apim-prod"
location = "eastus"
resource_group_name = azurerm_resource_group.main.name
publisher_name = "Platform Team"
publisher_email = "platform@example.com"
apis = local.apis
}
module "dns" {
source = "git::https://github.com/myorg/terraform-modules.git//modules/dns?ref=v1.0.3"
zone_name = "api.example.com"
gateway_url = module.apim.gateway_url
}
Each child owns one concern. The root owns the wiring. Nobody edits child module internals from the root, and no child reaches into another child's state. Data flows through outputs, in one direction.
Version pinning discipline
Pin everything. Module sources get ?ref=. Providers get version constraints in versions.tf. Terraform itself gets required_version in the root.
Then automate the upgrades. Renovate or Dependabot can open pull requests when new module tags land. Each PR shows a plan diff against the new version. You review the diff, merge, and apply. Upgrades become routine instead of scary, and you never discover six months later that half your environments run a different module version than the other half.
One rule for the team: no PR merges with an unpinned module source. Enforce it in CI with a simple grep if you have to. The discipline matters more than the tool.
Module testing basics
Untested modules rot. Someone changes a default, nothing validates it, and three teams discover the breakage on their next apply. Three levels of testing, cheapest first:
-
terraform validateandtflintin CI on every pull request. Catches syntax errors, deprecated syntax, and style issues. Fast and free. - Example configurations in
examples/folders that get planned in CI. If the example does not plan cleanly, the module is broken. This catches the most common real-world breakage: a variable change that no longer matches how callers use the module. - Terratest (Go) for modules that must actually apply. It provisions real infrastructure, asserts on outputs, then destroys. Slower and costs cloud spend, so reserve it for widely used modules where a bad release hurts many teams.
At minimum, do levels 1 and 2. They catch most breakage for almost no cost.
Anti-patterns
Mega-modules. One module that provisions networking, gateways, databases, and DNS is not reusable. It is a snowflake with inputs. Split by concern. A module should do one thing well enough that a team can adopt it without adopting everything around it.
Unpinned versions. A source with no ?ref= is a time bomb. Today it works. Tomorrow someone pushes a breaking change and your next init breaks every environment at once. Pin always.
Provider config in child modules. Child modules must never declare provider blocks with credentials or aliases they invent. Providers are configured in the root and passed down. A child that configures its own provider cannot be reused across accounts, regions, or environments.
Boolean sprawl. A module with fifteen boolean flags (enable_x, create_y, skip_z) is twelve modules wearing a trench coat. If the flags cluster into real use cases, split the module along those lines. A few well-chosen flags are fine. Fifteen is a design smell.
Outputs nobody uses. Every output is a promise to maintain. If no caller reads it, delete it. Review outputs when you review the module.
Checklist
- [ ] One concern per module. No mega-modules.
- [ ] Every variable has a type, a description, and validation where it matters.
- [ ] Outputs expose what callers need: IDs, URLs, names. Nothing more.
- [ ] README with a working example that actually plans.
- [ ] All module sources pinned with
?ref=tags. No exceptions. - [ ] Providers declared in
versions.tf, configured only in the root module. - [ ] Semantic versioning on releases. Breaking changes bump major.
- [ ]
validate,tflint, and example plans running in CI. - [ ] Common variable names across cloud-specific modules where the concepts genuinely match.
Next
Modules give you reusable building blocks with versioned releases. But who runs them, and when? If engineers still apply from laptops, you have versioned modules and unversioned process.
Next article: Terraform in CI/CD with GitHub Actions. Plan/apply gates, OIDC auth with no stored secrets, plan output posted as PR comments for review, and multi-environment promotion from dev to prod.
Top comments (0)