2026-07-30 · 7 min read
Build a Serverless REST API on AWS in 15 Minutes with Terraform
A step-by-step guide to deploying a production-ready serverless API, Lambda + API Gateway + DynamoDB, with least-privilege IAM, unit tests, and CI. All Terraform, zero console clicking.

You need a REST API. It should handle CRUD operations, scale to zero when idle, cost nothing at rest, and deploy repeatably without clicking through the AWS console. This post walks you through exactly that: a production-ready serverless API built with Terraform in about 15 minutes.
What You Get
By the end of this guide you'll have:
- A CRUD REST API (create, read, update, delete) accessible over HTTPS
- Scale-to-zero: no requests, no compute, no cost
- Pay-per-request pricing on both Lambda and DynamoDB (on-demand mode)
- Least-privilege IAM: the Lambda can only touch its own table and log group
- Unit tests and CI so you can iterate with confidence
- One command to deploy (or destroy) the entire stack
Total monthly cost for a low-traffic API? Likely under $0.01. The free tier alone covers 1M Lambda requests and 25 GB of DynamoDB storage.
Architecture
The request flow is deliberately simple, three managed services, no servers to patch:
Client → API Gateway (HTTP API) → Lambda (Python) → DynamoDB
- API Gateway HTTP API: routes requests, handles CORS, and provides a public HTTPS endpoint. HTTP APIs are faster and cheaper than REST APIs for this use case.
- Lambda: a single Python function handles all routes via event routing. Cold starts are sub-200 ms with a lean deployment package.
- DynamoDB: an on-demand (PAY_PER_REQUEST) table stores items keyed by
id. No capacity planning needed.
The Terraform Breakdown
The infrastructure is split into logical resources. Here are the key pieces:
DynamoDB Table
resource "aws_dynamodb_table" "items" {
name = "${var.project}-items"
billing_mode = "PAY_PER_REQUEST"
hash_key = "id"
attribute {
name = "id"
type = "S"
}
tags = local.tags
}
On-demand billing means you never provision read/write capacity, DynamoDB scales automatically and you pay only for what you use.
Lambda Function
resource "aws_lambda_function" "api" {
function_name = "${var.project}-handler"
runtime = "python3.12"
handler = "handler.lambda_handler"
filename = data.archive_file.lambda_zip.output_path
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
role = aws_iam_role.lambda.arn
timeout = 10
memory_size = 128
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.items.name
}
}
}
The table name is injected as an environment variable, no hardcoded ARNs in application code.
API Gateway HTTP API
resource "aws_apigatewayv2_api" "http" {
name = "${var.project}-api"
protocol_type = "HTTP"
cors_configuration {
allow_origins = ["*"]
allow_methods = ["GET", "POST", "PUT", "DELETE"]
allow_headers = ["Content-Type"]
}
}
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" "catch_all" {
api_id = aws_apigatewayv2_api.http.id
route_key = "$default"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
A $default catch-all route sends every request to the Lambda. Routing logic lives in Python, not in Terraform, easier to test, easier to change.
The Lambda Handler
A single Python file handles all CRUD operations by inspecting the HTTP method and path:
import json
import os
import uuid
import boto3
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
def lambda_handler(event, context):
method = event["requestContext"]["http"]["method"]
path = event.get("rawPath", "/")
if method == "GET" and path == "/items":
result = table.scan()
return _response(200, result["Items"])
if method == "GET" and path.startswith("/items/"):
item_id = path.split("/")[-1]
result = table.get_item(Key={"id": item_id})
item = result.get("Item")
if not item:
return _response(404, {"error": "Not found"})
return _response(200, item)
if method == "POST" and path == "/items":
body = json.loads(event.get("body", "{}"))
body["id"] = str(uuid.uuid4())
table.put_item(Item=body)
return _response(201, body)
if method == "PUT" and path.startswith("/items/"):
item_id = path.split("/")[-1]
body = json.loads(event.get("body", "{}"))
body["id"] = item_id
table.put_item(Item=body)
return _response(200, body)
if method == "DELETE" and path.startswith("/items/"):
item_id = path.split("/")[-1]
table.delete_item(Key={"id": item_id})
return _response(204, None)
return _response(404, {"error": "Not found"})
def _response(status, body):
return {
"statusCode": status,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body, default=str) if body else "",
}
Simple, readable, and easy to extend. Each route is a clear if block, no framework overhead for a function this small.
IAM Least-Privilege Design
This is where most tutorials cut corners. The Lambda role in this project is scoped to only the resources it needs:
data "aws_iam_policy_document" "lambda_permissions" {
# DynamoDB: only this specific table
statement {
effect = "Allow"
actions = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:Scan",
]
resources = [aws_dynamodb_table.items.arn]
}
# CloudWatch Logs: only this function's log group
statement {
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = ["${aws_cloudwatch_log_group.lambda.arn}:*"]
}
}
No wildcard resources. No dynamodb:*. No managed policy that grants access to every table in the account. The function can read/write its own table and write to its own log group, nothing else.
Why this matters:
- If the function is compromised, the blast radius is one table and one log group
- It passes any reasonable security review or compliance audit
- It's the AWS Well-Architected way
Testing Approach
The project includes unit tests that exercise the handler logic without deploying anything:
import json
from unittest.mock import patch, MagicMock
from handler import lambda_handler
@patch("handler.table")
def test_create_item(mock_table):
event = {
"requestContext": {"http": {"method": "POST"}},
"rawPath": "/items",
"body": json.dumps({"name": "Test Item"}),
}
response = lambda_handler(event, None)
assert response["statusCode"] == 201
body = json.loads(response["body"])
assert "id" in body
assert body["name"] == "Test Item"
mock_table.put_item.assert_called_once()
Tests mock the DynamoDB table resource so they run instantly with no AWS credentials. CI runs these on every push, the GitHub Actions workflow installs dependencies and runs pytest in seconds.
How to Deploy
Prerequisites: Terraform ≥ 1.5, AWS CLI configured, Python 3.12+.
# Clone the repo
git clone https://github.com/durrello/aws-serverless-api-terraform.git
cd aws-serverless-api-terraform
# Initialize and deploy
terraform init
terraform apply -auto-approve
Terraform outputs the API endpoint:
Outputs:
api_endpoint = "https://abc123.execute-api.us-east-1.amazonaws.com"
Test it immediately:
# Create an item
curl -X POST https://abc123.execute-api.us-east-1.amazonaws.com/items \
-H "Content-Type: application/json" \
-d '{"name": "Hello Serverless", "status": "active"}'
# List all items
curl https://abc123.execute-api.us-east-1.amazonaws.com/items
To tear everything down:
terraform destroy -auto-approve
Zero resources left behind, zero ongoing cost.
Wrapping Up
This stack gives you a production-grade pattern: a serverless CRUD API that costs nothing at rest, scales automatically, deploys in one command, and follows AWS security best practices. Use it as a foundation, add authentication (Cognito or API keys), request validation, or swap DynamoDB for Aurora Serverless when your data model outgrows key-value.
The full source, Terraform, Lambda code, tests, and CI pipeline, is on GitHub:
👉 github.com/durrello/aws-serverless-api-terraform
Clone it, deploy it, break it apart, and make it yours.