API Gateway with Terraform: patterns, pitfalls and fixes
When you need to expose Lambda functions or microservices to the world, API Gateway is often your first choice—but configuring it by hand is error-prone and doesn't scale. Using Terraform to codify your API Gateway setup gives you repeatability and safety, yet the configuration surface is wide enough that teams regularly stumble over routes, authorizers, and domain bindings. This guide walks through the patterns that work, the pitfalls we've hit in production, and the fixes that save time.
HTTP APIs are simpler and cheaper
AWS offers two types of API Gateway: REST APIs and HTTP APIs. REST APIs are feature-rich but verbose and costlier; HTTP APIs are lightweight, faster to deploy, and typically 70% cheaper. For most microservice workloads, HTTP APIs are sufficient. They support integrations with Lambda, HTTP endpoints, and AWS services, plus authorization via JWT. The trade-off is that HTTP APIs don't support resource-based policies or request/response mapping templates in the same way—but in practice, you handle transformations in your Lambda code.
In Terraform, you create an HTTP API with aws_apigatewayv2_api. Here's the bare minimum:
resource "aws_apigatewayv2_api" "main" {
name = "my-service-api"
protocol_type = "HTTP"
description = "HTTP API for service"
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.main.id
name = "prod"
auto_deploy = true
}
Setting auto_deploy = true means changes to routes and integrations deploy automatically—convenient for development, though some teams prefer explicit deployments for production safety. A best practice is to use a local variable or conditional to switch this behavior between environments.
Routes and integrations
Routes map HTTP methods and paths to integrations. In HTTP APIs, an integration points to a Lambda function, HTTP endpoint, or AWS service. A common mistake is forgetting that routes and integrations are separate resources; you define both, then link them.
Let's build a service with two endpoints: POST /orders to create and GET /orders/{id} to fetch:
resource "aws_apigatewayv2_integration" "create_order" {
api_id = aws_apigatewayv2_api.main.id
integration_type = "AWS_PROXY"
integration_method = "POST"
payload_format_version = "2.0"
target = aws_lambda_function.create_order.arn
}
resource "aws_apigatewayv2_route" "create_order" {
api_id = aws_apigatewayv2_api.main.id
route_key = "POST /orders"
target = "integrations/${aws_apigatewayv2_integration.create_order.id}"
}
resource "aws_apigatewayv2_integration" "get_order" {
api_id = aws_apigatewayv2_api.main.id
integration_type = "AWS_PROXY"
integration_method = "POST"
payload_format_version = "2.0"
target = aws_lambda_function.get_order.arn
}
resource "aws_apigatewayv2_route" "get_order" {
api_id = aws_apigatewayv2_api.main.id
route_key = "GET /orders/{id}"
target = "integrations/${aws_apigatewayv2_integration.get_order.id}"
}
Notice AWS_PROXY mode: API Gateway passes the full request to Lambda and expects Lambda to return a properly formatted response. This is simpler than AWS mode (which requires mapping templates) and is the default for most teams.
The route_key format is crucial. It follows the pattern METHOD /path and supports path parameters like {id}. The target references the integration using integrations/${integration_id}. One team we worked with accidentally hardcoded the integration ID, then renamed the resource; Terraform destroyed and recreated the route, breaking the deployment. Always use the interpolation syntax.
Lambda permissions deserve a mention here. The Lambda function needs permission to be invoked by API Gateway. Add this for each Lambda:
resource "aws_lambda_permission" "api_gateway_create_order" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.create_order.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
}
JWT authorizers
Most production APIs need authorization. HTTP APIs support JWT authorizers, which validate JSON Web Tokens and pass claims to your Lambda code. This is far cleaner than implementing token validation inside every Lambda function.
Set up an authorizer that validates tokens against an issuer (like Auth0, Okta, or your own identity service):
resource "aws_apigatewayv2_authorizer" "jwt" {
api_id = aws_apigatewayv2_api.main.id
authorizer_type = "JWT"
name = "jwt-authorizer"
identity_source = "$request.header.Authorization"
jwt_configuration {
audience = ["https://api.example.com"]
issuer = "https://auth.example.com"
}
}
Then attach it to routes that need protection:
resource "aws_apigatewayv2_route" "create_order" {
api_id = aws_apigatewayv2_api.main.id
route_key = "POST /orders"
target = "integrations/${aws_apigatewayv2_integration.create_order.id}"
authorizer_id = aws_apigatewayv2_authorizer.jwt.id
}
A pitfall: if the JWT issuer or audience configuration doesn't match your token, API Gateway returns a 401 before reaching Lambda—which is correct behavior but can be surprising during testing. Always validate your token structure with a tool like jwt.io before deploying. Also, the authorizer will cache token validation for a few minutes by default; if you're rotating credentials, be aware of this latency.
Not all routes need auth. Public endpoints (like GET /health) can skip the authorizer. You can also create a second authorizer for API keys if you need different strategies per route.
Custom domains with ACM
By default, API Gateway assigns you a URL like abcd1234.execute-api.us-east-1.amazonaws.com. For production, you'll want a custom domain like api.example.com. This requires an ACM certificate and a domain mapping.
First, create or import an ACM certificate:
resource "aws_acm_certificate" "api" {
domain_name = "api.example.com"
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "api_cert_validation" {
for_each = {
for dvo in aws_acm_certificate.api.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
}
allow_overwrite = true
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
zone_id = data.aws_route53_zone.main.zone_id
depends_on = [aws_acm_certificate.api]
}
resource "aws_acm_certificate_validation" "api" {
certificate_arn = aws_acm_certificate.api.arn
timeouts {
create = "5m"
}
depends_on = [aws_route53_record.api_cert_validation]
}
Then create the domain mapping for your HTTP API:
resource "aws_apigatewayv2_domain_name" "api" {
domain_name = "api.example.com"
domain_name_certificate_arn = aws_acm_certificate_validation.api.certificate_arn
depends_on = [aws_acm_certificate_validation.api]
}
resource "aws_apigatewayv2_api_mapping" "api" {
api_id = aws_apigatewayv2_api.main.id
domain_name = aws_apigatewayv2_domain_name.api.domain_name
stage = aws_apigatewayv2_stage.default.name
}
Finally, create a Route53 record pointing your domain to the API Gateway endpoint:
resource "aws_route53_record" "api" {
zone_id = data.aws_route53_zone.main.zone_id
name = "api.example.com"
type = "A"
alias {
name = aws_apigatewayv2_domain_name.api.domain_name_attributes[0].target_domain_name
zone_id = aws_apigatewayv2_domain_name.api.domain_name_attributes[0].hosted_zone_id
evaluate_target_health = false
}
}
One common pitfall: the certificate validation can take several minutes. If you apply Terraform before validation completes, the domain mapping will fail. Use depends_on and be patient. Also, ensure your domain's DNS is managed by the Route53 zone you're referencing; if it's elsewhere, Terraform won't be able to create the validation records.
Common mistakes
Forgetting Lambda permissions. Routes exist but Lambda invocation fails with 500. Always grant lambda:InvokeFunction permission with principal apigateway.amazonaws.com and source ARN matching your API's execution ARN.
Hardcoding integration IDs in routes. Using string literals instead of interpolation breaks when you refactor. Always use integrations/${aws_apigatewayv2_integration.example.id}.
Mismatched payload format versions. HTTP APIs use payload format version 2.0 by default; make sure your Lambda handler expects that structure (it differs from REST API payload format).
ACM certificate in wrong region. If your domain uses a cert in us-east-1 but your API is in eu-west-1, the mapping fails. API Gateway domain names require the certificate to be in us-east-1 (or the region where your domain is hosted for regional APIs).
Not testing JWT configuration. The issuer and audience must match your token exactly. If testing locally, use a tool like jwt.io to decode and verify your token structure before deploying.
Key takeaways
- Use HTTP APIs over REST APIs for new projects—they're cheaper and sufficient for most microservice patterns.
- Routes and integrations are separate resources; link them using interpolation, never hardcoded IDs.
- Always grant Lambda invoke permissions with the correct principal and source ARN.
- JWT authorizers validate tokens before Lambda runs, reducing boilerplate in your functions.
- Custom domains require ACM validation to complete before mapping; use
depends_onand test certificate issuer/audience matches. - Set
auto_deploy = falsefor production stages to control when changes go live. - Payload format version 2.0 is standard for HTTP APIs; ensure your Lambda code handles the correct event structure.






Comments
Loading comments…