Serverless CRUD API: API Gateway + Lambda + DynamoDB with IAM Auth
Scenario
You are building a serverless items catalog API. The API must support full CRUD operations — create, read, update, and delete items — each handled by a dedicated Lambda function. Data is stored in DynamoDB. The API must be protected: only callers with valid AWS credentials and the correct IAM permission can invoke it.
Rather than copy-pasting four Lambda resources, write a single resource block driven by for_each over a map of operations. Each Lambda gets its own CloudWatch Log Group, and all four functions share one least-privilege IAM role.
- Create a DynamoDB table (PAY_PER_REQUEST) with item_id as partition key, TTL, and PITR enabled.
- Define a map variable with 4 operations: create, get, update, delete — each with its HTTP method and path.
- Use for_each to create one Lambda function, one CloudWatch Log Group, and one API Gateway method+integration per operation from a single resource block each.
- Grant the Lambda role least-privilege DynamoDB access scoped to the specific table ARN.
- Configure API Gateway methods with AWS_IAM authorization and deploy to a named stage.
- Output the API base URL and a map of operation → Lambda ARN.
Additional Context
IAM vs Cognito vs API Key authorization: AWS_IAM uses AWS Signature Version 4 (SigV4) — callers sign their requests with their AWS credentials. This is the most secure option for internal service-to-service calls. Cognito User Pools are used for end-user authentication (generating JWTs). API Keys are the weakest option — suitable only for rate limiting, not authentication.
Each Lambda function needs an aws_lambda_permission resource granting API Gateway the right to invoke it. When using for_each for both Lambdas and permissions, the source_arn must include the specific HTTP method and resource path for the least-privilege principle to apply.
variables.tf
variable "aws_region" { type = string; default = "us-east-1" }
variable "api_name" { type = string; default = "items-api" }
variable "environment" { type = string; default = "prod" }
variable "operations" {
type = map(object({ http_method = string; path = string }))
default = {
"create" = { http_method = "POST"; path = "items" }
"get" = { http_method = "GET"; path = "items" }
"update" = { http_method = "PUT"; path = "items" }
"delete" = { http_method = "DELETE"; path = "items" }
}
}main.tf
# main.tf
provider "aws" { region = var.aws_region }
locals { tags = { Environment = var.environment, ManagedBy = "terraform" } }
# DynamoDB table for the items catalog.
resource "aws_dynamodb_table" "items" {
name = "items-${var.environment}"
billing_mode = "PAY_PER_REQUEST"
hash_key = "item_id"
attribute { name = "item_id"; type = "S" }
ttl { enabled = true; attribute_name = "expires_at" }
point_in_time_recovery { enabled = true }
tags = local.tags
}
# Shared Lambda execution role (all 4 functions assume this role).
data "aws_iam_policy_document" "lambda_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals { type = "Service"; identifiers = ["lambda.amazonaws.com"] }
}
}
resource "aws_iam_role" "lambda" {
name = "${var.api_name}-lambda-role"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_role_policy_attachment" "lambda_logs" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# Least-privilege DynamoDB policy scoped to just the items table.
data "aws_iam_policy_document" "dynamo_access" {
statement {
sid = "CRUDItems"
effect = "Allow"
actions = ["dynamodb:PutItem","dynamodb:GetItem","dynamodb:UpdateItem",
"dynamodb:DeleteItem","dynamodb:Query","dynamodb:Scan"]
resources = [aws_dynamodb_table.items.arn]
}
}
resource "aws_iam_role_policy" "dynamo" {
name = "dynamo-crud"
role = aws_iam_role.lambda.id
policy = data.aws_iam_policy_document.dynamo_access.json
}
# One CloudWatch Log Group per Lambda function.
resource "aws_cloudwatch_log_group" "lambda" {
for_each = var.operations
name = "/aws/lambda/${var.api_name}-${each.key}-${var.environment}"
retention_in_days = 30
tags = local.tags
}
# One Lambda per operation — driven entirely by for_each.
resource "aws_lambda_function" "ops" {
for_each = var.operations
function_name = "${var.api_name}-${each.key}-${var.environment}"
runtime = "python3.12"
handler = "handler.lambda_handler"
role = aws_iam_role.lambda.arn
filename = "${path.module}/lambda.zip"
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.items.name
OPERATION = each.key
}
}
depends_on = [aws_cloudwatch_log_group.lambda]
tags = merge(local.tags, { Operation = each.key })
}
# REST API.
resource "aws_api_gateway_rest_api" "main" {
name = var.api_name
endpoint_configuration { types = ["REGIONAL"] }
}
# Resource, method, and Lambda proxy integration — one of each per operation.
resource "aws_api_gateway_resource" "ops" {
for_each = var.operations
rest_api_id = aws_api_gateway_rest_api.main.id
parent_id = aws_api_gateway_rest_api.main.root_resource_id
path_part = each.value.path
}
resource "aws_api_gateway_method" "ops" {
for_each = var.operations
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.ops[each.key].id
http_method = each.value.http_method
authorization = "AWS_IAM"
}
resource "aws_api_gateway_integration" "ops" {
for_each = var.operations
rest_api_id = aws_api_gateway_rest_api.main.id
resource_id = aws_api_gateway_resource.ops[each.key].id
http_method = aws_api_gateway_method.ops[each.key].http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.ops[each.key].invoke_arn
}
resource "aws_lambda_permission" "ops" {
for_each = var.operations
statement_id = "AllowAPIGW-${each.key}"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.ops[each.key].function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.main.execution_arn}/*/*"
}
resource "aws_api_gateway_deployment" "main" {
rest_api_id = aws_api_gateway_rest_api.main.id
triggers = {
redeployment = sha1(jsonencode([for k in keys(var.operations) : aws_api_gateway_integration.ops[k].id]))
}
lifecycle { create_before_destroy = true }
depends_on = [aws_api_gateway_integration.ops]
}
resource "aws_api_gateway_stage" "main" {
deployment_id = aws_api_gateway_deployment.main.id
rest_api_id = aws_api_gateway_rest_api.main.id
stage_name = var.environment
}outputs.tf
output "api_base_url" { value = aws_api_gateway_stage.main.invoke_url }
output "table_name" { value = aws_dynamodb_table.items.name }
output "lambda_arns" {
value = { for k, v in aws_lambda_function.ops : k => v.arn }
}Workflow Commands
terraform init
terraform apply
BASE=$(terraform output -raw api_base_url)
# Create an item (sign requests with AWS SigV4 using awscurl)
awscurl --service execute-api -X POST "$BASE/items" \
-d '{"item_id":"abc-123","name":"Hammer","price":9.99}'
# Get items
awscurl --service execute-api "$BASE/items"
# Delete
awscurl --service execute-api -X DELETE "$BASE/items" \
-d '{"item_id":"abc-123"}'
# View state addresses — note map keys, not numeric indices
terraform state list | grep aws_lambda_function
terraform destroyApply complete! Resources: 19 added, 0 changed, 0 destroyed.
Outputs:
api_base_url = "https://abc1234.execute-api.us-east-1.amazonaws.com/prod"
lambda_arns = {
"create" = "arn:aws:lambda:us-east-1:123456789012:function:items-api-create-prod"
"delete" = "arn:aws:lambda:us-east-1:123456789012:function:items-api-delete-prod"
"get" = "arn:aws:lambda:us-east-1:123456789012:function:items-api-get-prod"
"update" = "arn:aws:lambda:us-east-1:123456789012:function:items-api-update-prod"
}
table_name = "items-prod"
