AWS · Terraform example
Terraform Example: Serverless API with API Gateway, Lambda and DynamoDB
A complete Terraform example for a serverless REST API on AWS: an API Gateway HTTP API, a Node.js Lambda function, and a DynamoDB table with least-privilege IAM.
Updated Nov 2026
This example builds a small serverless REST API on AWS. API Gateway receives HTTP requests, a Lambda function handles them, and DynamoDB stores the data. There are no servers to patch, and you pay per request.
What it creates
| Resource | Purpose |
|---|---|
aws_dynamodb_table | On-demand table with point-in-time recovery |
aws_lambda_function | Node.js 20 handler, packaged from ./src |
aws_iam_role and policies | Lambda can write logs and use this one table, nothing else |
aws_cloudwatch_log_group | Function logs, kept for 30 days |
aws_apigatewayv2_api, routes, stage | HTTP API with four routes and automatic deploys |
aws_lambda_permission | Lets this API, and only this API, invoke the function |
The Terraform
Save as main.tf, with your handler in src/index.mjs. It works unchanged with OpenTofu.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.4"
}
}
}
provider "aws" {
region = var.region
}
variable "region" {
type = string
default = "us-east-1"
}
variable "name" {
type = string
default = "items-api"
}
resource "aws_dynamodb_table" "items" {
name = var.name
billing_mode = "PAY_PER_REQUEST"
hash_key = "id"
attribute {
name = "id"
type = "S"
}
point_in_time_recovery {
enabled = true
}
}
# Package ./src/index.mjs into a zip for Lambda.
data "archive_file" "lambda" {
type = "zip"
source_dir = "${path.module}/src"
output_path = "${path.module}/build/lambda.zip"
}
data "aws_iam_policy_document" "assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "lambda" {
name = "${var.name}-lambda"
assume_role_policy = data.aws_iam_policy_document.assume.json
}
resource "aws_iam_role_policy_attachment" "logs" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# Least privilege: this table only, item-level actions only.
data "aws_iam_policy_document" "table" {
statement {
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:Scan"]
resources = [aws_dynamodb_table.items.arn]
}
}
resource "aws_iam_role_policy" "table" {
name = "table-access"
role = aws_iam_role.lambda.id
policy = data.aws_iam_policy_document.table.json
}
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${var.name}"
retention_in_days = 30
}
resource "aws_lambda_function" "api" {
function_name = var.name
role = aws_iam_role.lambda.arn
runtime = "nodejs20.x"
handler = "index.handler"
filename = data.archive_file.lambda.output_path
source_code_hash = data.archive_file.lambda.output_base64sha256
timeout = 10
memory_size = 256
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.items.name
}
}
depends_on = [aws_cloudwatch_log_group.lambda]
}
resource "aws_apigatewayv2_api" "http" {
name = var.name
protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
api_id = aws_apigatewayv2_api.http.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.api.invoke_arn
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "items" {
for_each = toset(["GET /items", "POST /items", "GET /items/{id}", "DELETE /items/{id}"])
api_id = aws_apigatewayv2_api.http.id
route_key = each.value
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.http.id
name = "$default"
auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.api.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*"
}
output "api_url" {
value = aws_apigatewayv2_api.http.api_endpoint
}
A minimal handler to start from, in src/index.mjs:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "node:crypto";
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TableName = process.env.TABLE_NAME;
export const handler = async (event) => {
const id = event.pathParameters?.id;
switch (event.routeKey) {
case "GET /items":
return json(200, (await db.send(new ScanCommand({ TableName }))).Items);
case "POST /items": {
const item = { id: randomUUID(), ...JSON.parse(event.body ?? "{}") };
await db.send(new PutCommand({ TableName, Item: item }));
return json(201, item);
}
case "GET /items/{id}": {
const { Item } = await db.send(new GetCommand({ TableName, Key: { id } }));
return Item ? json(200, Item) : json(404, { message: "Not found" });
}
case "DELETE /items/{id}":
await db.send(new DeleteCommand({ TableName, Key: { id } }));
return json(204, null);
}
};
const json = (statusCode, body) => ({
statusCode,
headers: { "content-type": "application/json" },
body: body === null ? "" : JSON.stringify(body),
});
The AWS SDK v3 is included in the Node.js 20 Lambda runtime, so no node_modules folder is needed.
Deploy it
terraform init
terraform plan -out=tfplan
terraform apply tfplan
curl -X POST "$(terraform output -raw api_url)/items" -d '{"name":"first"}'
curl "$(terraform output -raw api_url)/items"
Choices this example makes
- On-demand DynamoDB. No capacity planning, and you pay per request. Switch to provisioned capacity with auto scaling once traffic is steady and predictable.
- Least-privilege IAM. The function can use four actions on one table. Avoid
dynamodb:*andAmazonDynamoDBFullAccess. - One function, four routes. Simple to start with. Split into one function per route when the routes need different permissions or memory.
Scanfor listing. Fine for small tables. For large ones, design a key you canQueryinstead.
Cost
At low traffic this example costs very little and much of it falls within the AWS free tier. You pay per API request, per Lambda invocation and duration, and per DynamoDB read and write. Check current pricing for your expected volume.
Before production
- Add authentication: a JWT authorizer on the HTTP API, or IAM authorization.
- Add throttling on the stage and alarms on Lambda errors and duration.
- Store state remotely with locking, and run the plan through policy checks before apply. See policy-as-code patterns.
- Deploy from CI with short-lived credentials. See GitHub Actions OIDC for Terraform.
