Quick take — A production-ready Terraform module for AWS API Gateway HTTP APIs (apigatewayv2): routes, integrations, JWT authorizers, stages with throttling, and access logging — wired for hashicorp/aws ~> 5.0. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "api_gateway_http" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-api-gateway-http?ref=v1.0.0"
name = "..." # Name of the HTTP API; also names the authorizer and log…
integrations = {} # Backend integrations keyed by name; supports `AWS_PROXY…
routes = {} # Routes keyed by route key (`"GET /items"`), each pointi…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS API Gateway HTTP APIs (the apigatewayv2 family) are the leaner, cheaper successor to REST APIs. They were designed for the common case: front a Lambda function, an Application Load Balancer, or any HTTP backend, add JWT-based authorization, and ship. Compared with REST (apigateway) APIs, HTTP APIs cost roughly 70% less per million requests, add lower latency, and natively support JWT authorizers and automatic CORS — at the price of dropping some advanced features like request/response transformations and API keys/usage plans.
The trouble is that a single working HTTP API is rarely one resource. It is an aws_apigatewayv2_api, plus one aws_apigatewayv2_integration per backend, one aws_apigatewayv2_route per METHOD /path, an aws_apigatewayv2_stage (with throttling and access logs), a CloudWatch log group, and — if you front Lambda — an aws_lambda_permission so the gateway is allowed to invoke it. Hand-rolling that for every service leads to copy-paste drift: one team forgets access logging, another hardcodes $default throttling, a third leaves CORS wide open.
This module wraps aws_apigatewayv2_api and its satellites behind a small, opinionated variable surface. You declare the protocol, a map of routes, an optional JWT authorizer, CORS, and throttling — and you get back a deployed HTTP API with a usable invoke URL, sane defaults, and consistent tagging across every environment.
When to use it
Reach for this module when:
- You are fronting Lambda functions or an ALB/NLB and want a thin, low-latency HTTP front door rather than the heavier REST API.
- You need JWT authorization against Amazon Cognito, Auth0, Okta, or any OIDC issuer, without writing a custom Lambda authorizer.
- You want automatic CORS handling configured declaratively instead of per-route OPTIONS plumbing.
- You are standardizing many microservice APIs and want every one to ship with access logging, throttling, and consistent tags by default.
Prefer the REST API (aws_api_gateway_rest_api) module instead when you need API keys and usage plans, request/response mapping templates, AWS WAF on the API directly (HTTP APIs require a custom-domain/CloudFront fronting for WAF), edge-optimized endpoints, or fine-grained per-method caching.
Module structure
terraform-module-aws-api-gateway-http/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Normalize the optional CORS block: only emit a cors_configuration
# when at least one CORS field is actually set.
cors_enabled = var.cors_configuration != null
# Build the access-log format once so the stage stays readable.
access_log_format = jsonencode({
requestId = "$context.requestId"
ip = "$context.identity.sourceIp"
requestTime = "$context.requestTime"
httpMethod = "$context.httpMethod"
routeKey = "$context.routeKey"
status = "$context.status"
protocol = "$context.protocol"
responseLength = "$context.responseLength"
integrationErrorMessage = "$context.integrationErrorMessage"
integrationLatency = "$context.integrationLatency"
responseLatency = "$context.responseLatency"
})
}
# ---------------------------------------------------------------------------
# The HTTP API
# ---------------------------------------------------------------------------
resource "aws_apigatewayv2_api" "this" {
name = var.name
protocol_type = "HTTP"
description = var.description
# Disable the default https://{api-id}.execute-api.{region}.amazonaws.com
# endpoint when you front the API with a custom domain.
disable_execute_api_endpoint = var.disable_execute_api_endpoint
dynamic "cors_configuration" {
for_each = local.cors_enabled ? [var.cors_configuration] : []
content {
allow_origins = cors_configuration.value.allow_origins
allow_methods = cors_configuration.value.allow_methods
allow_headers = cors_configuration.value.allow_headers
expose_headers = cors_configuration.value.expose_headers
allow_credentials = cors_configuration.value.allow_credentials
max_age = cors_configuration.value.max_age
}
}
tags = var.tags
}
# ---------------------------------------------------------------------------
# Optional JWT authorizer (Cognito / Auth0 / Okta / any OIDC issuer)
# ---------------------------------------------------------------------------
resource "aws_apigatewayv2_authorizer" "jwt" {
count = var.jwt_authorizer != null ? 1 : 0
api_id = aws_apigatewayv2_api.this.id
authorizer_type = "JWT"
identity_sources = ["$request.header.Authorization"]
name = "${var.name}-jwt"
jwt_configuration {
audience = var.jwt_authorizer.audience
issuer = var.jwt_authorizer.issuer
}
}
# ---------------------------------------------------------------------------
# Integrations — one per backend, keyed by the integration name
# ---------------------------------------------------------------------------
resource "aws_apigatewayv2_integration" "this" {
for_each = var.integrations
api_id = aws_apigatewayv2_api.this.id
integration_type = each.value.integration_type
integration_uri = each.value.integration_uri
integration_method = each.value.integration_method
connection_type = each.value.connection_type
connection_id = each.value.connection_id
payload_format_version = each.value.payload_format_version
timeout_milliseconds = each.value.timeout_milliseconds
}
# ---------------------------------------------------------------------------
# Routes — keyed by route key ("GET /items", "POST /orders", "$default")
# ---------------------------------------------------------------------------
resource "aws_apigatewayv2_route" "this" {
for_each = var.routes
api_id = aws_apigatewayv2_api.this.id
route_key = each.key
target = "integrations/${aws_apigatewayv2_integration.this[each.value.integration_key].id}"
# Attach the JWT authorizer only when the route opts in AND one exists.
authorization_type = (
each.value.authorized && var.jwt_authorizer != null ? "JWT" : "NONE"
)
authorizer_id = (
each.value.authorized && var.jwt_authorizer != null
? aws_apigatewayv2_authorizer.jwt[0].id
: null
)
authorization_scopes = each.value.authorized ? each.value.scopes : null
}
# ---------------------------------------------------------------------------
# Access-log group for the stage
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_log_group" "access" {
name = "/aws/apigateway/${var.name}/${var.stage_name}"
retention_in_days = var.log_retention_in_days
kms_key_id = var.log_kms_key_arn
tags = var.tags
}
# ---------------------------------------------------------------------------
# Stage — throttling, auto-deploy, access logging
# ---------------------------------------------------------------------------
resource "aws_apigatewayv2_stage" "this" {
api_id = aws_apigatewayv2_api.this.id
name = var.stage_name
auto_deploy = true
default_route_settings {
throttling_burst_limit = var.throttling_burst_limit
throttling_rate_limit = var.throttling_rate_limit
detailed_metrics_enabled = var.detailed_metrics_enabled
}
access_log_settings {
destination_arn = aws_cloudwatch_log_group.access.arn
format = local.access_log_format
}
tags = var.tags
}
# ---------------------------------------------------------------------------
# Allow API Gateway to invoke any AWS_PROXY (Lambda) integration
# ---------------------------------------------------------------------------
resource "aws_lambda_permission" "this" {
for_each = {
for k, v in var.integrations : k => v
if v.integration_type == "AWS_PROXY" && v.lambda_function_name != null
}
statement_id = "AllowAPIGatewayInvoke-${each.key}"
action = "lambda:InvokeFunction"
function_name = each.value.lambda_function_name
principal = "apigateway.amazonaws.com"
# Scope the permission to this API so an ARN leak can't be replayed elsewhere.
source_arn = "${aws_apigatewayv2_api.this.execution_arn}/*/*"
}
variables.tf
variable "name" {
description = "Name of the HTTP API. Used to name the API, authorizer, and log group."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9-_]{1,128}$", var.name))
error_message = "name must be 1-128 chars: letters, digits, hyphens, underscores."
}
}
variable "description" {
description = "Description applied to the HTTP API."
type = string
default = null
}
variable "stage_name" {
description = "Stage name to deploy (e.g. 'prod', 'v1'). Use '$default' for the implicit stage."
type = string
default = "$default"
}
variable "disable_execute_api_endpoint" {
description = "Disable the default execute-api endpoint (set true when fronting with a custom domain)."
type = bool
default = false
}
variable "integrations" {
description = <<-EOT
Map of backend integrations keyed by a stable integration name (referenced from `routes`).
For Lambda, set integration_type = "AWS_PROXY", integration_uri = the function invoke ARN,
and lambda_function_name so the module can create the invoke permission.
EOT
type = map(object({
integration_type = string
integration_uri = string
integration_method = optional(string)
connection_type = optional(string, "INTERNET")
connection_id = optional(string)
payload_format_version = optional(string, "2.0")
timeout_milliseconds = optional(number, 30000)
lambda_function_name = optional(string)
}))
validation {
condition = alltrue([
for k, v in var.integrations :
contains(["AWS_PROXY", "HTTP_PROXY"], v.integration_type)
])
error_message = "integration_type must be AWS_PROXY or HTTP_PROXY for HTTP APIs."
}
validation {
condition = alltrue([
for k, v in var.integrations :
v.timeout_milliseconds >= 50 && v.timeout_milliseconds <= 30000
])
error_message = "timeout_milliseconds must be between 50 and 30000 for HTTP APIs."
}
}
variable "routes" {
description = <<-EOT
Map of routes keyed by route key, e.g. "GET /items", "POST /orders", "$default".
Each route points at an integration via integration_key and may require JWT auth.
EOT
type = map(object({
integration_key = string
authorized = optional(bool, false)
scopes = optional(list(string), [])
}))
validation {
condition = length(var.routes) > 0
error_message = "At least one route is required."
}
}
variable "jwt_authorizer" {
description = "Optional JWT authorizer. issuer is the OIDC issuer URL; audience is the list of accepted audiences/client IDs."
type = object({
issuer = string
audience = list(string)
})
default = null
}
variable "cors_configuration" {
description = "Optional CORS configuration for the API. Leave null to disable CORS."
type = object({
allow_origins = optional(list(string), [])
allow_methods = optional(list(string), [])
allow_headers = optional(list(string), [])
expose_headers = optional(list(string), [])
allow_credentials = optional(bool, false)
max_age = optional(number, 0)
})
default = null
}
variable "throttling_burst_limit" {
description = "Default route throttling burst limit for the stage."
type = number
default = 500
}
variable "throttling_rate_limit" {
description = "Default route throttling steady-state rate limit (requests/sec) for the stage."
type = number
default = 1000
}
variable "detailed_metrics_enabled" {
description = "Enable detailed (per-route) CloudWatch metrics on the stage."
type = bool
default = true
}
variable "log_retention_in_days" {
description = "Retention for the access-log CloudWatch log group."
type = number
default = 30
validation {
condition = contains(
[1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653],
var.log_retention_in_days
)
error_message = "log_retention_in_days must be a valid CloudWatch retention value."
}
}
variable "log_kms_key_arn" {
description = "Optional KMS key ARN to encrypt the access-log group. Null uses the default CloudWatch encryption."
type = string
default = null
}
variable "tags" {
description = "Tags applied to all taggable resources created by the module."
type = map(string)
default = {}
}
outputs.tf
output "api_id" {
description = "The identifier of the HTTP API."
value = aws_apigatewayv2_api.this.id
}
output "api_name" {
description = "The name of the HTTP API."
value = aws_apigatewayv2_api.this.name
}
output "api_arn" {
description = "The ARN of the HTTP API."
value = aws_apigatewayv2_api.this.arn
}
output "execution_arn" {
description = "Execution ARN — use as the source_arn base for Lambda/IAM permissions."
value = aws_apigatewayv2_api.this.execution_arn
}
output "api_endpoint" {
description = "The default https endpoint of the HTTP API (https://{api-id}.execute-api.{region}.amazonaws.com)."
value = aws_apigatewayv2_api.this.api_endpoint
}
output "invoke_url" {
description = "Fully-qualified invoke URL including the stage path."
value = aws_apigatewayv2_stage.this.invoke_url
}
output "stage_id" {
description = "The identifier of the deployed stage."
value = aws_apigatewayv2_stage.this.id
}
output "authorizer_id" {
description = "The JWT authorizer ID, or null when no authorizer is configured."
value = try(aws_apigatewayv2_authorizer.jwt[0].id, null)
}
output "log_group_name" {
description = "Name of the CloudWatch access-log group for the stage."
value = aws_cloudwatch_log_group.access.name
}
How to use it
# An existing Lambda we want to front with the HTTP API.
data "aws_lambda_function" "orders" {
function_name = "orders-service"
}
module "api_gateway_http_orders" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-api-gateway-http?ref=v1.0.0"
name = "orders-api"
description = "Public HTTP API for the orders microservice"
stage_name = "prod"
integrations = {
orders_lambda = {
integration_type = "AWS_PROXY"
integration_uri = data.aws_lambda_function.orders.invoke_arn
lambda_function_name = data.aws_lambda_function.orders.function_name
payload_format_version = "2.0"
}
}
routes = {
"GET /orders" = { integration_key = "orders_lambda", authorized = true, scopes = ["orders.read"] }
"POST /orders" = { integration_key = "orders_lambda", authorized = true, scopes = ["orders.write"] }
"GET /orders/{id}" = { integration_key = "orders_lambda", authorized = true, scopes = ["orders.read"] }
"GET /health" = { integration_key = "orders_lambda", authorized = false }
}
# Validate Cognito-issued JWTs.
jwt_authorizer = {
issuer = "https://cognito-idp.ap-south-1.amazonaws.com/ap-south-1_AbCdEf123"
audience = ["4f9k2m7p1q8r3s6t0u5v9w2x"]
}
cors_configuration = {
allow_origins = ["https://app.kloudvin.com"]
allow_methods = ["GET", "POST", "OPTIONS"]
allow_headers = ["authorization", "content-type"]
max_age = 300
}
throttling_burst_limit = 1000
throttling_rate_limit = 2000
log_retention_in_days = 90
tags = {
Environment = "prod"
Team = "commerce"
ManagedBy = "Terraform"
}
}
# Downstream reference: publish the invoke URL to SSM so the web app reads it.
resource "aws_ssm_parameter" "orders_api_url" {
name = "/commerce/orders-api/invoke-url"
type = "String"
value = module.api_gateway_http_orders.invoke_url
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/api_gateway_http/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-api-gateway-http?ref=v1.0.0"
}
inputs = {
name = "..."
integrations = {}
routes = {}
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/api_gateway_http && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Name of the HTTP API; also names the authorizer and log group. |
description |
string |
null |
No | Description applied to the HTTP API. |
stage_name |
string |
"$default" |
No | Stage to deploy (e.g. prod, v1), or $default for the implicit stage. |
disable_execute_api_endpoint |
bool |
false |
No | Disable the default execute-api endpoint when fronting with a custom domain. |
integrations |
map(object) |
— | Yes | Backend integrations keyed by name; supports AWS_PROXY (Lambda) and HTTP_PROXY (ALB/HTTP). |
routes |
map(object) |
— | Yes | Routes keyed by route key ("GET /items"), each pointing at an integration_key. |
jwt_authorizer |
object |
null |
No | Optional JWT authorizer with OIDC issuer and accepted audience list. |
cors_configuration |
object |
null |
No | Optional declarative CORS config; null disables CORS. |
throttling_burst_limit |
number |
500 |
No | Default-route throttling burst limit for the stage. |
throttling_rate_limit |
number |
1000 |
No | Default-route steady-state rate limit (req/s) for the stage. |
detailed_metrics_enabled |
bool |
true |
No | Enable per-route CloudWatch metrics on the stage. |
log_retention_in_days |
number |
30 |
No | Retention for the access-log group (valid CloudWatch value). |
log_kms_key_arn |
string |
null |
No | KMS key ARN to encrypt the access-log group. |
tags |
map(string) |
{} |
No | Tags applied to all taggable resources. |
Outputs
| Name | Description |
|---|---|
api_id |
The identifier of the HTTP API. |
api_name |
The name of the HTTP API. |
api_arn |
The ARN of the HTTP API. |
execution_arn |
Execution ARN — base for Lambda/IAM source_arn permissions. |
api_endpoint |
Default https endpoint (https://{api-id}.execute-api.{region}.amazonaws.com). |
invoke_url |
Fully-qualified invoke URL including the stage path. |
stage_id |
The identifier of the deployed stage. |
authorizer_id |
The JWT authorizer ID, or null when none is configured. |
log_group_name |
Name of the CloudWatch access-log group for the stage. |
Enterprise scenario
A retail platform runs roughly 40 microservices, each fronted by its own HTTP API. The platform team consumes this module from a shared Terraform monorepo, passing each service a route map and a common jwt_authorizer block pointing at the corporate Cognito user pool, so every API validates the same OIDC tokens and scopes. Because access logging, 2000 req/s throttling, and Team/Environment tags are baked into the module defaults, a new service goes from PR to production-grade HTTP front door in minutes — and the SecOps team can query one consistent JSON access-log format across all 40 APIs in CloudWatch Logs Insights.
Best practices
- Always require JWT on data routes, keep only
/healthopen. Setauthorized = true(with scopes) on every route that touches data, and lean onauthorization_scopesso a token fororders.readcan’t hit a write route — defense in depth beyond just “is the token valid.” - Lock CORS down to known origins. Never ship
allow_origins = ["*"]withallow_credentials = true(API Gateway will reject that combination anyway). Enumerate your real front-end origins and the minimal header/method set. - Right-size throttling per API, and watch the account-level cap. HTTP APIs share a default account-level quota of 10,000 req/s; per-API
throttling_rate_limit/throttling_burst_limitprotect noisy-neighbor backends and Lambda concurrency from a traffic spike on one route. - Front with a custom domain + WAF for anything public. Set
disable_execute_api_endpoint = true, attach anaws_apigatewayv2_domain_name, and put CloudFront/WAF in front — HTTP APIs can’t take a WAF web ACL directly the way REST APIs can. - Keep the structured access log and a retention policy. The JSON format here (status,
integrationLatency,integrationErrorMessage) is what you’ll actually query during an incident; pair it with a sanelog_retention_in_daysand alog_kms_key_arnto control both cost and compliance. - Name and tag consistently. Use a
service-apinaming convention fornameand passEnvironment/Team/ManagedBytags so APIs, log groups, and cost allocation all line up across accounts.