← Back to blog

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.

#aws#serverless#lambda#terraform#api-gateway#dynamodb
Build a Serverless REST API on AWS in 15 Minutes with Terraform

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.

Share:LinkedInXWhatsApp

Related articles

Reactions & comments