Terraform for API Gateway and SQS for Event-Driven Ansible
A common pattern in event-driven architectures is exposing an HTTP endpoint that drops messages onto a queue for downstream processing. AWS API Gateway has a native integration with SQS that lets you do this without writing a Lambda. The gateway talks directly to the SQS service. This post walks through a Terraform configuration that provisions the entire infrastructure. This will support one or more Event-Driven Ansible rulesbooks using the builtin collection
What gets built
The configuration creates:
- An SQS queue to receive messages
- A Dead Letter Queue (DLQ) with a redrive policy
- An IAM role and policy granting API Gateway permission to send to the queue
- A REST API Gateway with a POST endpoint that forwards payloads to SQS
- CORS support via an OPTIONS preflight method
- A deployment and stage to make the endpoint live
The SQS queues
Two queues are created: the main queue and a DLQ. The redrive policy ties them together and sets a maxReceiveCount of 3, meaning a message will be retried up to three times before being sent to the DLQ.
resource "aws_sqs_queue" "api_gateway_queue" {
name = var.sqs_queue_name
delay_seconds = 0
max_message_size = 262144
message_retention_seconds = 1209600
receive_wait_time_seconds = 0
visibility_timeout_seconds = 30
tags = var.tags
}
resource "aws_sqs_queue" "api_gateway_dlq" {
name = "${var.sqs_queue_name}-dlq"
tags = var.tags
}
resource "aws_sqs_queue_redrive_policy" "api_gateway_queue_redrive" {
queue_url = aws_sqs_queue.api_gateway_queue.id
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.api_gateway_dlq.arn
maxReceiveCount = 3
})
}
The 14-day retention period (1209600 seconds) gives consumers plenty of time to process messages or investigate failures before they expire.
IAM: least-privilege access
API Gateway needs an IAM role it can assume, and a policy that grants only sqs:SendMessage and sqs:GetQueueAttributes on the specific queue ARN.
resource "aws_iam_role" "api_gateway_sqs_role" {
name = "${var.project_name}-api-gateway-sqs-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "apigateway.amazonaws.com" }
}]
})
tags = var.tags
}
resource "aws_iam_policy" "api_gateway_sqs_policy" {
name = "${var.project_name}-api-gateway-sqs-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["sqs:SendMessage", "sqs:GetQueueAttributes"]
Resource = aws_sqs_queue.api_gateway_queue.arn
}]
})
tags = var.tags
}
resource "aws_iam_role_policy_attachment" "api_gateway_sqs_attachment" {
role = aws_iam_role.api_gateway_sqs_role.name
policy_arn = aws_iam_policy.api_gateway_sqs_policy.arn
}
The API Gateway integration
This is where the process kicks off. The API Gateway has a single resource path (/messages), and accepts POST requests. The integration type is `AWS’. It call an AWS service directly. In this case we are pointing directly to the SQS queue.
resource "aws_api_gateway_integration" "sqs_integration" {
rest_api_id = aws_api_gateway_rest_api.sqs_api.id
resource_id = aws_api_gateway_resource.sqs_resource.id
http_method = aws_api_gateway_method.sqs_method.http_method
integration_http_method = "POST"
type = "AWS"
uri = "arn:aws:apigateway:${data.aws_region.current.name}:sqs:path/${data.aws_caller_identity.current.account_id}/${aws_sqs_queue.api_gateway_queue.name}"
credentials = aws_iam_role.api_gateway_sqs_role.arn
request_parameters = {
"integration.request.header.Content-Type" = "'application/x-www-form-urlencoded'"
}
request_templates = {
"application/json" = "Action=SendMessage&MessageBody=$util.urlEncode($input.body)"
}
depends_on = [aws_iam_role_policy_attachment.api_gateway_sqs_attachment]
}
A few things worth noting here:
The Content-Type override: SQS’s HTTP API expects application/x-www-form-urlencoded, not JSON. The request_parameters block forces that header on the outbound integration request regardless of what the caller sends.
The request template: The request_templates block uses the Velocity Template Language (VTL) built into API Gateway. It formats the outbound body as an SQS SendMessage action, URL-encoding the original JSON payload as the MessageBody. This is the glue that translates an incoming JSON POST into something SQS understands.
The depends_on: The integration explicitly waits for the IAM policy attachment to complete. Without this, Terraform might try to create the integration before the role has permission to call SQS, resulting in a permissions error at deploy time.
CORS preflight
If any browser-based client will POST to this endpoint, a CORS preflight OPTIONS request must be handled. A MOCK integration is used here and no backend call is made. The response is generated entirely within API Gateway using static response headers.
resource "aws_api_gateway_integration" "sqs_options_integration" {
rest_api_id = aws_api_gateway_rest_api.sqs_api.id
resource_id = aws_api_gateway_resource.sqs_resource.id
http_method = aws_api_gateway_method.sqs_options.http_method
type = "MOCK"
request_templates = {
"application/json" = jsonencode({ statusCode = 200 })
}
}
The integration response for OPTIONS sets the three required CORS headers: Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers.
Deployment and stage
API Gateway changes don’t go live until a deployment resource is created. The configuration uses a triggers block to force a new deployment whenever any of the dependent method or integration resources change. This pattern helps avoids stale deployments.
resource "aws_api_gateway_deployment" "sqs_deployment" {
rest_api_id = aws_api_gateway_rest_api.sqs_api.id
triggers = {
redeployment = sha1(jsonencode([
aws_api_gateway_resource.sqs_resource,
aws_api_gateway_method.sqs_method,
aws_api_gateway_integration.sqs_integration,
aws_api_gateway_method.sqs_options,
aws_api_gateway_integration.sqs_options_integration,
]))
}
lifecycle {
create_before_destroy = true
}
}
The create_before_destroy lifecycle rule ensures zero-downtime deployments. Terraform creates the new deployment before destroying the old one, so there’s no gap in availability.
Variables and outputs
All configurable values are externalized in variables.tf, including the queue name, API name, resource path, stage name, throttle limits, and CORS origin. This makes the configuration reusable across environments without modifying the resource definitions.
After apply, outputs.tf shows the values you’ll actually need:
api_gateway_invoke_url— the full URL to POST messages tosqs_queue_urlandsqs_queue_arn— for any consumer configurationsqs_dlq_url— for dead letter monitoringiam_role_arn— useful for auditing or cross-referencing
Wrapping up
Here is an example of a test api call using curl:
curl -X POST https://<api-id>.execute-api.<region>.amazonaws.com/prod/messages \
-H "Content-Type: application/json" \
-d '{"event": "server.alert", "host": "web-01", "severity": "critical"}'
The native AWS service integration in API Gateway is a clean way to expose a queue as an HTTP endpoint without managing additional compute. The main complexity is in the VTL request template and the Content-Type header override, but once you understand why they’re needed the pattern is straightforward to use again.
Thanks for reading. Feel free to reach out with questions or feedback.