IaC AWS

Terraform Module: AWS AppSync — A reusable managed GraphQL API with Cognito auth, logging, and Lambda data sources

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

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 configlive/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 configlive/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

TerraformAWSAppSyncModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading