Quick take — Wrap aws_appsync_graphql_api in a production-ready Terraform module: Cognito/IAM auth, CloudWatch field-level logging, X-Ray tracing, and a Lambda-backed resolver, all driven by clean variables. 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 "appsync" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-appsync?ref=v1.0.0"
name = "..." # API name; prefixes the logging/data-source IAM roles.
schema = "..." # GraphQL SDL schema, typically `file("schema.graphql")`.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS AppSync is a fully managed service that lets you stand up a GraphQL (and, more recently, an Events) API without running your own GraphQL server. It handles the resolver execution, schema validation, subscription fan-out over WebSockets, caching, and integration with backends such as DynamoDB, Lambda, Aurora Serverless (RDS Data API), OpenSearch, and arbitrary HTTP endpoints. You hand it a schema and a set of resolvers, and AppSync gives you a single HTTPS endpoint plus real-time subscriptions.
The core Terraform resource is aws_appsync_graphql_api. On its own it is deceptively small, but a correct AppSync API in production drags in a cluster of tightly-coupled concerns that are easy to get wrong by hand: the authentication mode (API key vs. Cognito User Pool vs. IAM vs. OIDC vs. Lambda authorizer), additional auth providers, an IAM role that grants AppSync permission to write logs, the CloudWatch log configuration with a field-level log level, X-Ray tracing, the schema itself, and at least one data source plus resolver so the API actually returns data.
Wrapping all of this in a reusable module means every team in your org gets the same hardened baseline — least-privilege logging role, field-level logging at a sane level, tracing on, schema and resolver wired up — instead of copy-pasting a half-configured API that silently logs nothing and uses an API_KEY that expires in 7 days.
When to use it
- You are building a GraphQL-first backend (web/mobile clients, BFF layer) and want managed subscriptions and schema enforcement rather than rolling your own Apollo/GraphQL server on ECS or Lambda.
- You need per-field authorization with Cognito groups or an OIDC provider, and want the auth wiring captured as code and code-reviewed.
- You want a fan-out subscription layer (live dashboards, chat, presence, order tracking) without managing WebSocket connection state yourself.
- You are standardizing many small APIs across squads and want a paved-road module that enforces logging, tracing, and least-privilege IAM by default.
Reach for a different approach when you need arbitrary REST semantics with heavy request transformation (use API Gateway), or when your traffic is so high and uniform that a self-hosted GraphQL server is materially cheaper than AppSync’s per-request pricing.
Module structure
terraform-module-aws-appsync/
├── versions.tf
├── main.tf
├── variables.tf
├── outputs.tf
└── README.md
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# AppSync needs an IAM role to push field-level logs to CloudWatch Logs.
# AppSync writes to /aws/appsync/apis/<api-id>; we grant it that path.
create_logging_role = var.enable_logging && var.cloudwatch_logs_role_arn == null
logging_role_arn = var.enable_logging ? (
local.create_logging_role ? aws_iam_role.logs[0].arn : var.cloudwatch_logs_role_arn
) : null
common_tags = merge(
{
"Name" = var.name
"ManagedBy" = "terraform"
"Module" = "terraform-module-aws-appsync"
},
var.tags
)
}
# ---------------------------------------------------------------------------
# GraphQL API
# ---------------------------------------------------------------------------
resource "aws_appsync_graphql_api" "this" {
name = var.name
authentication_type = var.authentication_type
schema = var.schema
xray_enabled = var.xray_enabled
visibility = var.visibility
introspection_config = var.introspection_enabled ? "ENABLED" : "DISABLED"
query_depth_limit = var.query_depth_limit
resolver_count_limit = var.resolver_count_limit
# Primary Cognito User Pool config (only rendered when primary auth is Cognito).
dynamic "user_pool_config" {
for_each = var.authentication_type == "AMAZON_COGNITO_USER_POOLS" ? [var.user_pool_config] : []
content {
user_pool_id = user_pool_config.value.user_pool_id
aws_region = user_pool_config.value.aws_region
default_action = user_pool_config.value.default_action
app_id_client_regex = user_pool_config.value.app_id_client_regex
}
}
# Primary OIDC config (only rendered when primary auth is OIDC).
dynamic "openid_connect_config" {
for_each = var.authentication_type == "OPENID_CONNECT" ? [var.openid_connect_config] : []
content {
issuer = openid_connect_config.value.issuer
client_id = openid_connect_config.value.client_id
auth_ttl = openid_connect_config.value.auth_ttl
iat_ttl = openid_connect_config.value.iat_ttl
}
}
# Primary Lambda authorizer config (only rendered when primary auth is Lambda).
dynamic "lambda_authorizer_config" {
for_each = var.authentication_type == "AWS_LAMBDA" ? [var.lambda_authorizer_config] : []
content {
authorizer_uri = lambda_authorizer_config.value.authorizer_uri
authorizer_result_ttl_in_seconds = lambda_authorizer_config.value.authorizer_result_ttl_in_seconds
identity_validation_expression = lambda_authorizer_config.value.identity_validation_expression
}
}
# Secondary auth providers (e.g. API_KEY for service-to-service alongside Cognito).
dynamic "additional_authentication_provider" {
for_each = var.additional_authentication_providers
content {
authentication_type = additional_authentication_provider.value.authentication_type
dynamic "user_pool_config" {
for_each = additional_authentication_provider.value.user_pool_config != null ? [additional_authentication_provider.value.user_pool_config] : []
content {
user_pool_id = user_pool_config.value.user_pool_id
aws_region = user_pool_config.value.aws_region
app_id_client_regex = user_pool_config.value.app_id_client_regex
}
}
dynamic "openid_connect_config" {
for_each = additional_authentication_provider.value.openid_connect_config != null ? [additional_authentication_provider.value.openid_connect_config] : []
content {
issuer = openid_connect_config.value.issuer
client_id = openid_connect_config.value.client_id
auth_ttl = openid_connect_config.value.auth_ttl
iat_ttl = openid_connect_config.value.iat_ttl
}
}
}
}
dynamic "log_config" {
for_each = var.enable_logging ? [1] : []
content {
cloudwatch_logs_role_arn = local.logging_role_arn
field_log_level = var.field_log_level
exclude_verbose_content = var.exclude_verbose_content
}
}
tags = local.common_tags
}
# ---------------------------------------------------------------------------
# API key (optional) — only useful when API_KEY is a (primary or additional) auth type.
# ---------------------------------------------------------------------------
resource "aws_appsync_api_key" "this" {
count = var.create_api_key ? 1 : 0
api_id = aws_appsync_graphql_api.this.id
description = "${var.name} default API key (managed by terraform)"
expires = var.api_key_expires
}
# ---------------------------------------------------------------------------
# Logging IAM role (only created when logging is on and no role was supplied)
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "assume_logs" {
count = local.create_logging_role ? 1 : 0
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["appsync.amazonaws.com"]
}
}
}
resource "aws_iam_role" "logs" {
count = local.create_logging_role ? 1 : 0
name = "${var.name}-appsync-logs"
assume_role_policy = data.aws_iam_policy_document.assume_logs[0].json
tags = local.common_tags
}
resource "aws_iam_role_policy_attachment" "logs" {
count = local.create_logging_role ? 1 : 0
role = aws_iam_role.logs[0].name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSAppSyncPushToCloudWatchLogs"
}
# Pre-create the log group so retention is governed by IaC, not AppSync's default
# (AppSync auto-creates /aws/appsync/apis/<api-id> with "never expire" otherwise).
resource "aws_cloudwatch_log_group" "this" {
count = var.enable_logging ? 1 : 0
name = "/aws/appsync/apis/${aws_appsync_graphql_api.this.id}"
retention_in_days = var.log_retention_in_days
tags = local.common_tags
}
# ---------------------------------------------------------------------------
# Lambda data source + direct-Lambda resolver (the most common production wiring)
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "assume_appsync" {
count = var.lambda_data_source != null ? 1 : 0
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["appsync.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "invoke_lambda" {
count = var.lambda_data_source != null ? 1 : 0
statement {
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [var.lambda_data_source.function_arn]
}
}
resource "aws_iam_role" "lambda_ds" {
count = var.lambda_data_source != null ? 1 : 0
name = "${var.name}-appsync-lambda-ds"
assume_role_policy = data.aws_iam_policy_document.assume_appsync[0].json
tags = local.common_tags
}
resource "aws_iam_role_policy" "lambda_ds" {
count = var.lambda_data_source != null ? 1 : 0
name = "invoke-lambda"
role = aws_iam_role.lambda_ds[0].id
policy = data.aws_iam_policy_document.invoke_lambda[0].json
}
resource "aws_appsync_datasource" "lambda" {
count = var.lambda_data_source != null ? 1 : 0
api_id = aws_appsync_graphql_api.this.id
name = var.lambda_data_source.name
type = "AWS_LAMBDA"
service_role_arn = aws_iam_role.lambda_ds[0].arn
lambda_config {
function_arn = var.lambda_data_source.function_arn
}
}
resource "aws_appsync_resolver" "lambda" {
count = var.lambda_data_source != null ? 1 : 0
api_id = aws_appsync_graphql_api.this.id
type = var.lambda_data_source.resolver_type
field = var.lambda_data_source.resolver_field
data_source = aws_appsync_datasource.lambda[0].name
kind = "UNIT"
max_batch_size = var.lambda_data_source.max_batch_size
request_template = "$util.toJson($context.arguments)"
response_template = "$util.toJson($context.result)"
}
variables.tf
variable "name" {
description = "Name of the AppSync GraphQL API. Used as a prefix for the logging/data-source IAM roles."
type = string
validation {
condition = can(regex("^[A-Za-z0-9_-]{1,65}$", var.name))
error_message = "name must be 1-65 chars: letters, numbers, hyphens or underscores only."
}
}
variable "schema" {
description = "GraphQL SDL schema definition for the API. Pass via file(\"schema.graphql\")."
type = string
}
variable "authentication_type" {
description = "Primary authentication type for the API."
type = string
default = "AMAZON_COGNITO_USER_POOLS"
validation {
condition = contains(
["API_KEY", "AWS_IAM", "AMAZON_COGNITO_USER_POOLS", "OPENID_CONNECT", "AWS_LAMBDA"],
var.authentication_type
)
error_message = "authentication_type must be one of API_KEY, AWS_IAM, AMAZON_COGNITO_USER_POOLS, OPENID_CONNECT, AWS_LAMBDA."
}
}
variable "user_pool_config" {
description = "Cognito User Pool config. Required when authentication_type = AMAZON_COGNITO_USER_POOLS."
type = object({
user_pool_id = string
aws_region = string
default_action = optional(string, "DENY")
app_id_client_regex = optional(string)
})
default = null
validation {
condition = var.user_pool_config == null || contains(["ALLOW", "DENY"], try(var.user_pool_config.default_action, "DENY"))
error_message = "user_pool_config.default_action must be ALLOW or DENY."
}
}
variable "openid_connect_config" {
description = "OIDC config. Required when authentication_type = OPENID_CONNECT."
type = object({
issuer = string
client_id = optional(string)
auth_ttl = optional(number)
iat_ttl = optional(number)
})
default = null
}
variable "lambda_authorizer_config" {
description = "Lambda authorizer config. Required when authentication_type = AWS_LAMBDA."
type = object({
authorizer_uri = string
authorizer_result_ttl_in_seconds = optional(number, 300)
identity_validation_expression = optional(string)
})
default = null
}
variable "additional_authentication_providers" {
description = "Extra auth providers layered on top of the primary one (e.g. API_KEY for service callers alongside Cognito for users)."
type = list(object({
authentication_type = string
user_pool_config = optional(object({
user_pool_id = string
aws_region = string
app_id_client_regex = optional(string)
}))
openid_connect_config = optional(object({
issuer = string
client_id = optional(string)
auth_ttl = optional(number)
iat_ttl = optional(number)
}))
}))
default = []
}
variable "create_api_key" {
description = "Create a default API key (only meaningful when API_KEY is a primary or additional auth type)."
type = bool
default = false
}
variable "api_key_expires" {
description = "RFC3339 expiry timestamp for the API key (e.g. 2027-01-01T00:00:00Z). AppSync requires 1-365 days out."
type = string
default = null
}
variable "xray_enabled" {
description = "Enable AWS X-Ray tracing for the API."
type = bool
default = true
}
variable "visibility" {
description = "API endpoint visibility: GLOBAL (public) or PRIVATE (VPC-only via VPC endpoint)."
type = string
default = "GLOBAL"
validation {
condition = contains(["GLOBAL", "PRIVATE"], var.visibility)
error_message = "visibility must be GLOBAL or PRIVATE."
}
}
variable "introspection_enabled" {
description = "Allow GraphQL schema introspection. Disable in production to reduce attack surface."
type = bool
default = false
}
variable "query_depth_limit" {
description = "Max nested resolver depth per query (0 = no limit, max 75). Guards against deeply nested abusive queries."
type = number
default = 0
validation {
condition = var.query_depth_limit >= 0 && var.query_depth_limit <= 75
error_message = "query_depth_limit must be between 0 and 75."
}
}
variable "resolver_count_limit" {
description = "Max number of resolvers invoked per request (0 = default of 10000, max 10000)."
type = number
default = 0
validation {
condition = var.resolver_count_limit >= 0 && var.resolver_count_limit <= 10000
error_message = "resolver_count_limit must be between 0 and 10000."
}
}
variable "enable_logging" {
description = "Enable CloudWatch field-level logging for the API."
type = bool
default = true
}
variable "field_log_level" {
description = "Field-level log verbosity: NONE, ERROR, INFO, ALL, or DEBUG."
type = string
default = "ERROR"
validation {
condition = contains(["NONE", "ERROR", "INFO", "ALL", "DEBUG"], var.field_log_level)
error_message = "field_log_level must be one of NONE, ERROR, INFO, ALL, DEBUG."
}
}
variable "exclude_verbose_content" {
description = "Exclude request/response headers and resolver context from logs (recommended to avoid logging PII/tokens)."
type = bool
default = true
}
variable "cloudwatch_logs_role_arn" {
description = "Existing IAM role ARN AppSync should use to write logs. If null and logging is enabled, the module creates one."
type = string
default = null
}
variable "log_retention_in_days" {
description = "Retention for the AppSync 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 value accepted by CloudWatch Logs (e.g. 1, 7, 14, 30, 90, 365...)."
}
}
variable "lambda_data_source" {
description = "Optional Lambda-backed data source + direct resolver. Wires a single Query/Mutation field to a Lambda function."
type = object({
name = string
function_arn = string
resolver_type = string # "Query" or "Mutation"
resolver_field = string # e.g. "getOrder"
max_batch_size = optional(number, 0)
})
default = null
validation {
condition = var.lambda_data_source == null || contains(["Query", "Mutation"], try(var.lambda_data_source.resolver_type, ""))
error_message = "lambda_data_source.resolver_type must be Query or Mutation."
}
}
variable "tags" {
description = "Additional tags applied to all taggable resources."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "The AppSync GraphQL API ID."
value = aws_appsync_graphql_api.this.id
}
output "arn" {
description = "The ARN of the AppSync GraphQL API."
value = aws_appsync_graphql_api.this.arn
}
output "name" {
description = "The name of the AppSync GraphQL API."
value = aws_appsync_graphql_api.this.name
}
output "graphql_endpoint" {
description = "The HTTPS GraphQL endpoint (uris[\"GRAPHQL\"]) clients POST queries/mutations to."
value = aws_appsync_graphql_api.this.uris["GRAPHQL"]
}
output "realtime_endpoint" {
description = "The WebSocket endpoint (uris[\"REALTIME\"]) used for GraphQL subscriptions."
value = try(aws_appsync_graphql_api.this.uris["REALTIME"], null)
}
output "api_key" {
description = "The generated API key value, if create_api_key = true (sensitive)."
value = try(aws_appsync_api_key.this[0].key, null)
sensitive = true
}
output "logging_role_arn" {
description = "ARN of the IAM role AppSync uses to push logs (module-created or supplied)."
value = local.logging_role_arn
}
output "lambda_data_source_name" {
description = "Name of the Lambda data source, if created."
value = try(aws_appsync_datasource.lambda[0].name, null)
}
How to use it
The example below stands up a Cognito-authenticated Orders API, adds API_KEY as a secondary auth type for an internal reporting service, and wires the getOrder query straight to a Lambda function. A downstream aws_ssm_parameter then publishes the GraphQL endpoint for client apps to discover.
module "appsync" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-appsync?ref=v1.0.0"
name = "orders-api-prod"
authentication_type = "AMAZON_COGNITO_USER_POOLS"
schema = file("${path.module}/schema.graphql")
user_pool_config = {
user_pool_id = aws_cognito_user_pool.customers.id
aws_region = "ap-south-1"
default_action = "DENY"
}
# Internal reporting service authenticates with a long-lived API key.
additional_authentication_providers = [
{
authentication_type = "API_KEY"
}
]
create_api_key = true
api_key_expires = "2027-06-09T00:00:00Z"
# Production hardening
xray_enabled = true
introspection_enabled = false
query_depth_limit = 8
field_log_level = "ERROR"
log_retention_in_days = 90
# Wire the getOrder query to a Lambda resolver.
lambda_data_source = {
name = "orders_fn"
function_arn = aws_lambda_function.orders.arn
resolver_type = "Query"
resolver_field = "getOrder"
}
tags = {
Environment = "prod"
CostCenter = "retail-platform"
}
}
# Downstream: publish the endpoint so frontend pipelines can fetch it at build time.
resource "aws_ssm_parameter" "graphql_endpoint" {
name = "/orders/prod/appsync/graphql-endpoint"
type = "String"
value = module.appsync.graphql_endpoint
}
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/appsync/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-appsync?ref=v1.0.0"
}
inputs = {
name = "..."
schema = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/appsync && 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 | API name; prefixes the logging/data-source IAM roles. |
schema |
string |
— | Yes | GraphQL SDL schema, typically file("schema.graphql"). |
authentication_type |
string |
"AMAZON_COGNITO_USER_POOLS" |
No | Primary auth: API_KEY, AWS_IAM, AMAZON_COGNITO_USER_POOLS, OPENID_CONNECT, AWS_LAMBDA. |
user_pool_config |
object |
null |
Conditional | Cognito config; required when primary auth is Cognito. |
openid_connect_config |
object |
null |
Conditional | OIDC config; required when primary auth is OIDC. |
lambda_authorizer_config |
object |
null |
Conditional | Lambda authorizer config; required when primary auth is AWS_LAMBDA. |
additional_authentication_providers |
list(object) |
[] |
No | Secondary auth providers layered on the primary. |
create_api_key |
bool |
false |
No | Create a default API key (needs API_KEY as an auth type). |
api_key_expires |
string |
null |
No | RFC3339 expiry for the API key (1–365 days out). |
xray_enabled |
bool |
true |
No | Enable X-Ray tracing. |
visibility |
string |
"GLOBAL" |
No | GLOBAL (public) or PRIVATE (VPC-only). |
introspection_enabled |
bool |
false |
No | Allow schema introspection. |
query_depth_limit |
number |
0 |
No | Max nested resolver depth (0–75; 0 = unlimited). |
resolver_count_limit |
number |
0 |
No | Max resolvers per request (0–10000; 0 = default 10000). |
enable_logging |
bool |
true |
No | Enable CloudWatch field-level logging. |
field_log_level |
string |
"ERROR" |
No | NONE, ERROR, INFO, ALL, or DEBUG. |
exclude_verbose_content |
bool |
true |
No | Exclude headers/context from logs (avoids logging tokens/PII). |
cloudwatch_logs_role_arn |
string |
null |
No | Existing logging role ARN; module creates one if null. |
log_retention_in_days |
number |
30 |
No | Retention for the AppSync log group. |
lambda_data_source |
object |
null |
No | Optional Lambda data source + direct resolver for one field. |
tags |
map(string) |
{} |
No | Extra tags on all taggable resources. |
Outputs
| Name | Description |
|---|---|
id |
The AppSync GraphQL API ID. |
arn |
The ARN of the GraphQL API. |
name |
The API name. |
graphql_endpoint |
HTTPS endpoint for queries/mutations (uris["GRAPHQL"]). |
realtime_endpoint |
WebSocket endpoint for subscriptions (uris["REALTIME"]). |
api_key |
Generated API key value when create_api_key = true (sensitive). |
logging_role_arn |
ARN of the IAM role AppSync uses to write logs. |
lambda_data_source_name |
Name of the Lambda data source, if created. |
Enterprise scenario
A retail platform team runs a customer-facing Orders API consumed by their iOS, Android, and web storefronts. End users authenticate through a Cognito User Pool (with default_action = "DENY" so every field must be explicitly authorized), while an internal analytics service reads order aggregates using a separate API_KEY provider declared as an additional authentication method. The same module is instantiated three times — orders-api-dev, orders-api-staging, orders-api-prod — from one pipeline, guaranteeing identical field-level logging at ERROR, X-Ray tracing, 90-day log retention, and introspection disabled in prod, so a misconfigured environment can never ship a chatty or wide-open API.
Best practices
- Lock down auth and the schema surface. Prefer
AMAZON_COGNITO_USER_POOLSorOPENID_CONNECToverAPI_KEYfor anything user-facing, setdefault_action = "DENY"so fields are deny-by-default, and turnintrospection_enabled = falsein production. Usequery_depth_limitandresolver_count_limitto cap abusive deeply-nested queries. - Never log secrets. Keep
exclude_verbose_content = trueso auth headers and resolver context (which can contain tokens or PII) stay out of CloudWatch, and run withfield_log_level = "ERROR"in prod — bump toALL/DEBUGonly while actively debugging, since verbose logging is both a cost and a compliance liability. - Govern log retention and cost in IaC. AppSync auto-creates its log group with never-expire retention; this module pre-creates
/aws/appsync/apis/<api-id>with an explicitlog_retention_in_daysso storage cost stays bounded. Remember AppSync bills per query/data-modification operation and per real-time message — enable caching for hot read paths and avoidfield_log_levelhigher than you need. - Scope data-source IAM roles tightly. The Lambda data-source role here is granted
lambda:InvokeFunctionon exactly one function ARN, not*. Mirror that pattern for DynamoDB/RDS data sources — one role per data source, least privilege only. - Pin and version the module. Always consume it via a
?ref=vX.Y.Ztag (as in the example) rather than a moving branch, and keephashicorp/awsat~> 5.0so resolver and auth-config schema changes are picked up deliberately. - Name consistently for multi-env fleets. Use a
<domain>-api-<env>convention (e.g.orders-api-prod) so the derived-appsync-logsand-appsync-lambda-dsroles, log groups, and SSM parameters are immediately attributable to a service and environment.