AWS Terraform Lambda DynamoDB
A production‑ready, minimal‑cost serverless CRUD API using Amazon API Gateway (HTTP API v2), AWS Lambda (Python), and Amazon DynamoDB. Includes CORS, logging, throttling, infra-as-code with Terraform, and step-by-step commands.
✅ Default: No auth (public API)
🔐 Optional upgrades section shows how to add Cognito JWT auth, custom domain, and CI/CD.
Client (curl / Postman / Web App)
│
▼
API Gateway (HTTP API v2)
│ (AWS_PROXY, payload v2.0)
▼
Lambda (Python 3.11)
│
▼
DynamoDB (items table)
- CORS enabled for
*(customize later) - Access logs → CloudWatch Log Group
/aws/apigw/<project> - Lambda logs → CloudWatch Log Group
/aws/lambda/<function> - Throttling at stage level (burst/rate)
- AWS account with permissions to create: API Gateway v2 (HTTP), Lambda, IAM roles/policies, DynamoDB, CloudWatch Logs.
- AWS CLI v2 configured (
aws configure) - Terraform ≥ 1.6
- Python ≥ 3.10 (for local packaging / edits)
Recommended region:
us-east-1(changeable via Terraform var).
api-gateway-full-project/
├─ app/
│ └─ src/
│ └─ app.py # Lambda handler (CRUD)
├─ infra/
│ └─ terraform/
│ ├─ providers.tf
│ ├─ variables.tf
│ ├─ locals.tf
│ ├─ dynamodb.tf
│ ├─ lambda.tf
│ ├─ apigw_http.tf
│ ├─ outputs.tf
│ └─ versions.tf # (optional) provider pins, see providers.tf
├─ Makefile # quality-of-life commands (optional)
└─ README.md # quickstart (this doc content)
Terraform zips the Lambda code automatically; no manual packaging needed.
Place all files below under
infra/terraform/unless otherwise noted.
terraform {
required_version = ">= 1.6"
required_providers {
aws = { source = "hashicorp/aws", version = ">= 5.0" }
archive = { source = "hashicorp/archive", version = ">= 2.4" }
random = { source = "hashicorp/random", version = ">= 3.5" }
}
}
provider "aws" {
region = var.region
}variable "region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Prefix for all resource names"
type = string
default = "apigw-crud"
}locals {
lambda_name = "${var.project_name}-handler"
api_log_group = "/aws/apigw/${var.project_name}"
lambda_log_group = "/aws/lambda/${local.lambda_name}"
}
# Useful identity/partition data
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}resource "aws_dynamodb_table" "items" {
name = "${var.project_name}-items"
billing_mode = "PAY_PER_REQUEST"
hash_key = "id"
attribute {
name = "id"
type = "S"
}
point_in_time_recovery {
enabled = true
}
tags = {
Project = var.project_name
}
}# Log group for Lambda with retention
resource "aws_cloudwatch_log_group" "lambda" {
name = local.lambda_log_group
retention_in_days = 14
}
# Zip Lambda from repo's app/src directory
# NOTE: path.module points to infra/terraform, so ../.. goes to repo root
# Then /app/src is the code directory.
data "archive_file" "lambda_zip" {
type = "zip"
source_dir = "${path.module}/../../app/src"
output_path = "${path.module}/lambda.zip"
}
resource "aws_iam_role" "lambda" {
name = "${var.project_name}-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
# IAM policy: CloudWatch Logs + DynamoDB table access
resource "aws_iam_role_policy" "lambda" {
name = "${var.project_name}-lambda-policy"
role = aws_iam_role.lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow",
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
Resource = "*"
},
{
Effect = "Allow",
Action = [
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Scan"
],
Resource = aws_dynamodb_table.items.arn
}
]
})
}
resource "aws_lambda_function" "main" {
function_name = local.lambda_name
role = aws_iam_role.lambda.arn
filename = data.archive_file.lambda_zip.output_path
handler = "app.lambda_handler"
runtime = "python3.11"
timeout = 10
memory_size = 256
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.items.name
}
}
depends_on = [aws_cloudwatch_log_group.lambda]
}# Access logs for API Gateway
resource "aws_cloudwatch_log_group" "api" {
name = local.api_log_group
retention_in_days = 14
}
resource "aws_apigatewayv2_api" "http_api" {
name = "${var.project_name}-http"
protocol_type = "HTTP"
cors_configuration {
allow_origins = ["*"]
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allow_headers = ["*"]
}
}
resource "aws_apigatewayv2_integration" "lambda" {
api_id = aws_apigatewayv2_api.http_api.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.main.invoke_arn
payload_format_version = "2.0"
timeout_milliseconds = 29000
}
# Catch‑all route to Lambda (you can add explicit routes later)
resource "aws_apigatewayv2_route" "default" {
api_id = aws_apigatewayv2_api.http_api.id
route_key = "$default"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.http_api.id
name = "$default"
auto_deploy = true
access_log_settings {
destination_arn = aws_cloudwatch_log_group.api.arn
format = jsonencode({
requestId = "$context.requestId",
ip = "$context.identity.sourceIp",
httpMethod = "$context.httpMethod",
routeKey = "$context.routeKey",
status = "$context.status",
responseLength = "$context.responseLength",
integrationErrorMessage = "$context.integrationErrorMessage"
})
}
default_route_settings {
throttling_burst_limit = 100
throttling_rate_limit = 50
}
}
# Allow API Gateway to invoke Lambda
resource "aws_lambda_permission" "apigw_invoke" {
statement_id = "AllowInvokeFromAPIGateway"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.main.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.http_api.execution_arn}/*"
}output "api_base_url" {
description = "Invoke base URL for the HTTP API"
value = aws_apigatewayv2_api.http_api.api_endpoint
}
output "dynamodb_table" {
value = aws_dynamodb_table.items.name
}import json
import os
import uuid
import boto3
from boto3.dynamodb.conditions import Key
TABLE_NAME = os.environ.get("TABLE_NAME")
ddb = boto3.resource("dynamodb")
items = ddb.Table(TABLE_NAME)
# Helpers
def _resp(status, body=None):
return {
"statusCode": status,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
},
"body": json.dumps(body or {})
}
def lambda_handler(event, context):
# HTTP API v2.0 structure
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
raw_path = event.get("rawPath", "/")
qs = event.get("queryStringParameters") or {}
# Parse path: /items and /items/{id}
parts = [p for p in raw_path.split('/') if p]
resource = parts[0] if parts else ''
item_id = parts[1] if len(parts) > 1 else None
try:
if resource != 'items':
return _resp(404, {"message": "Not Found"})
if method == 'GET' and item_id:
# GET /items/{id}
res = items.get_item(Key={"id": item_id})
if 'Item' not in res:
return _resp(404, {"message": "Item not found"})
return _resp(200, res['Item'])
if method == 'GET':
# GET /items (scan; for production, consider query with indexes)
res = items.scan(Limit=100)
return _resp(200, res.get('Items', []))
if method == 'POST':
# POST /items (create)
body = json.loads(event.get('body') or '{}')
new_id = body.get('id') or str(uuid.uuid4())
item = {
'id': new_id,
**{k: v for k, v in body.items() if k != 'id'}
}
items.put_item(Item=item)
return _resp(201, item)
if method == 'PUT' and item_id:
# PUT /items/{id} (replace/merge)
body = json.loads(event.get('body') or '{}')
item = {'id': item_id, **body}
items.put_item(Item=item)
return _resp(200, item)
if method == 'DELETE' and item_id:
items.delete_item(Key={'id': item_id})
return _resp(204, {})
if method == 'OPTIONS':
return _resp(200, {})
return _resp(405, {"message": "Method Not Allowed"})
except Exception as e:
# Log full error in Lambda logs; return sanitized message to client
print(f"ERROR: {e}")
return _resp(500, {"message": "Internal Server Error"})From repo root (api-gateway-full-project/):
# 0) Optional: set region/profile
export AWS_REGION=us-east-1
export AWS_PROFILE=default # or your profile
# 1) Initialize & plan
cd infra/terraform
terraform init
terraform plan -var "region=${AWS_REGION}" -out tfplan
# 2) Apply
terraform apply tfplan
# 3) Grab API URL
terraform output api_base_url
# Example: https://abc123.execute-api.us-east-1.amazonaws.comAPI=$(terraform output -raw api_base_url)
# Create
curl -s -X POST "$API/items" \
-H 'Content-Type: application/json' \
-d '{"name":"first","status":"new"}' | jq .
# List
curl -s "$API/items" | jq .
# Get by id (replace <id>)
curl -s "$API/items/<id>" | jq .
# Update/replace
curl -s -X PUT "$API/items/<id>" \
-H 'Content-Type: application/json' \
-d '{"status":"updated"}' | jq .
# Delete
curl -s -X DELETE "$API/items/<id>" -i- API Gateway access logs: CloudWatch Log Group
/aws/apigw/<project> - Lambda logs: CloudWatch Log Group
/aws/lambda/<function>
aws logs tail "/aws/lambda/$(terraform output -raw dynamodb_table | sed "s/-items/-handler/")" --followterraform destroy -auto-approve- 403 from API Gateway: Ensure
aws_lambda_permission.apigw_invokesource_arnmatches execution ARN pattern and region. - 5XX responses: Check Lambda logs in CloudWatch for stack trace.
- Access denied on DynamoDB: Confirm Lambda IAM policy includes table ARN from Terraform (
aws_dynamodb_table.items.arn). - CORS errors in browsers: CORS is enabled broadly; narrow as needed in
aws_apigatewayv2_api.cors_configuration.
-
JWT auth (Amazon Cognito)
- Create a User Pool, App Client.
- Add
aws_apigatewayv2_authorizer(JWT) with issuerhttps://cognito-idp.<region>.amazonaws.com/<user_pool_id>and audience[app_client_id]. - Define explicit routes (e.g.,
GET /items,POST /items) withauthorization_type = "JWT"andauthorizer_idset.
-
Custom domain
- Request ACM certificate in the same region.
- Create
aws_apigatewayv2_domain_name,aws_apigatewayv2_api_mapping, and a Route53Aalias.
-
WAF v2
- Create
aws_wafv2_web_acland associate with the API stage if supported for your API type/region.
- Create
-
CI/CD
- Add GitHub Actions with AWS OIDC; run
terraform fmt/validate/plan/applyon main branch.
- Add GitHub Actions with AWS OIDC; run
-
Observability
- Emit structured app logs, add metrics via CloudWatch EMF, create alarms on 5XX and high latency.
- This template uses HTTP API (v2) for lower cost and latency; if you need API keys/usage plans/mapping templates, consider REST API (v1).
- The sample uses scan for listing items; for production, prefer Query with a GSI or well-designed keys.
- Packaging uses the
archive_filedata source — for external dependencies, use a build step (e.g.,pip install -t .) before zipping.
Place at repo root Makefile:
REGION ?= us-east-1
.PHONY: init plan apply destroy url
init:
cd infra/terraform && terraform init
plan:
cd infra/terraform && terraform plan -var "region=$(REGION)" -out tfplan
apply:
cd infra/terraform && terraform apply tfplan
url:
cd infra/terraform && terraform output api_base_url
destroy:
cd infra/terraform && terraform destroy -auto-approveSpin it up, hit the endpoints, and extend routes/logic as needed. For Authentication, custom domain, or REST API variant with API keys, start from the Optional Upgrades section.