DEV Community

Manoj Kumar Kagitha
Manoj Kumar Kagitha

Posted on

Reusable Terraform Modules for API Gateway Provisioning Across Azure and AWS

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
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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     = []
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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."
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. terraform validate and tflint in CI on every pull request. Catches syntax errors, deprecated syntax, and style issues. Fast and free.
  2. 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.
  3. 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)