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

ResourcePurpose
aws_dynamodb_tableOn-demand table with point-in-time recovery
aws_lambda_functionNode.js 20 handler, packaged from ./src
aws_iam_role and policiesLambda can write logs and use this one table, nothing else
aws_cloudwatch_log_groupFunction logs, kept for 30 days
aws_apigatewayv2_api, routes, stageHTTP API with four routes and automatic deploys
aws_lambda_permissionLets 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:* and AmazonDynamoDBFullAccess.
  • One function, four routes. Simple to start with. Split into one function per route when the routes need different permissions or memory.
  • Scan for listing. Fine for small tables. For large ones, design a key you can Query instead.

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.

FAQ

Should I use an HTTP API or a REST API in API Gateway?

Use an HTTP API (apigatewayv2) unless you need a REST API feature such as API keys with usage plans, request validation, or private endpoints. HTTP APIs cost less and have lower latency.

Why create the log group in Terraform?

If Lambda creates its own log group, it never expires and Terraform does not manage it. Creating it first lets you set retention and remove it with terraform destroy.

How do I deploy new function code?

Change the files in src and run terraform apply. The source_code_hash changes with the zip, so Terraform uploads the new code. Larger teams usually build the artifact in CI and pass its location to Terraform instead.

Platform evaluation

See OrchesTerra on your infrastructure

Request access to generate architecture from a repository, review the plan, and run governed reconciliation across AWS, Azure, GCP, and OCI.