Skip to main content

Skillber v1.0 is here!

Learn more

Module Project — Serverless API with Lambda, API Gateway & DynamoDB

Checking access...

In this project, you will deploy a serverless REST API for a notes application. The API supports CRUD operations backed by DynamoDB and secured via API Gateway.

Architecture

Client (HTTP)
┌──────────────────────┐
│ API Gateway (REST) │
│ /notes │
│ ─ GET → list │
│ ─ POST → create │
│ /notes/{id} │
│ ─ GET → read │
│ ─ PUT → update │
│ ─ DELETE → delete │
└──────────┬───────────┘
┌──────┴──────┐
│ Lambda │
│ Functions │
└──────┬──────┘
┌──────┴──────┐
│ DynamoDB │
│ NotesTable │
└─────────────┘

Prerequisites

  • AWS account with CLI configured
  • Terraform installed (v1.5+)
  • Node.js or Python runtime installed locally

Step 1: Lambda Function Code

Create lambda/index.py:

import json
import os
import boto3
import uuid
from datetime import datetime
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])
def lambda_handler(event, context):
http_method = event['httpMethod']
path_params = event.get('pathParameters') or {}
note_id = path_params.get('id')
if http_method == 'GET' and not note_id:
return list_notes()
elif http_method == 'GET' and note_id:
return get_note(note_id)
elif http_method == 'POST':
return create_note(json.loads(event['body']))
elif http_method == 'PUT' and note_id:
return update_note(note_id, json.loads(event['body']))
elif http_method == 'DELETE' and note_id:
return delete_note(note_id)
else:
return respond(400, {'error': 'Invalid request'})
def list_notes():
response = table.scan()
return respond(200, response['Items'])
def get_note(note_id):
response = table.get_item(Key={'id': note_id})
item = response.get('Item')
if not item:
return respond(404, {'error': 'Note not found'})
return respond(200, item)
def create_note(data):
note = {
'id': str(uuid.uuid4()),
'title': data['title'],
'content': data.get('content', ''),
'created_at': datetime.utcnow().isoformat(),
'updated_at': datetime.utcnow().isoformat()
}
table.put_item(Item=note)
return respond(201, note)
def update_note(note_id, data):
table.update_item(
Key={'id': note_id},
UpdateExpression='SET title = :t, content = :c, updated_at = :u',
ExpressionAttributeValues={
':t': data['title'],
':c': data.get('content', ''),
':u': datetime.utcnow().isoformat()
}
)
return respond(200, {'id': note_id, **data})
def delete_note(note_id):
table.delete_item(Key={'id': note_id})
return respond(200, {'message': 'Note deleted'})
def respond(status_code, body):
return {
'statusCode': status_code,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
'body': json.dumps(body)
}

Step 2: Terraform Configuration

Create main.tf:

provider "aws" {
region = "us-east-1"
}
# DynamoDB table
resource "aws_dynamodb_table" "notes" {
name = "NotesTable"
billing_mode = "PAY_PER_REQUEST"
hash_key = "id"
attribute {
name = "id"
type = "S"
}
}
# IAM role for Lambda
resource "aws_iam_role" "lambda_role" {
name = "notes-api-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
}
resource "aws_iam_role_policy" "lambda_policy" {
name = "notes-api-lambda-policy"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Scan"
]
Resource = aws_dynamodb_table.notes.arn
},
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "*"
}
]
})
}
# Lambda function
resource "aws_lambda_function" "notes_api" {
filename = "lambda.zip"
function_name = "notes-api"
role = aws_iam_role.lambda_role.arn
handler = "index.lambda_handler"
runtime = "python3.12"
timeout = 10
memory_size = 128
environment {
variables = {
TABLE_NAME = aws_dynamodb_table.notes.name
}
}
}
# API Gateway
resource "aws_api_gateway_rest_api" "notes_api" {
name = "Notes API"
description = "Serverless Notes API"
}
resource "aws_api_gateway_resource" "notes" {
rest_api_id = aws_api_gateway_rest_api.notes_api.id
parent_id = aws_api_gateway_rest_api.notes_api.root_resource_id
path_part = "notes"
}
resource "aws_api_gateway_resource" "note" {
rest_api_id = aws_api_gateway_rest_api.notes_api.id
parent_id = aws_api_gateway_resource.notes.id
path_part = "{id}"
}
# Methods - GET /notes
resource "aws_api_gateway_method" "list_notes" {
rest_api_id = aws_api_gateway_rest_api.notes_api.id
resource_id = aws_api_gateway_resource.notes.id
http_method = "GET"
authorization = "NONE"
}
resource "aws_api_gateway_integration" "list_notes_int" {
rest_api_id = aws_api_gateway_rest_api.notes_api.id
resource_id = aws_api_gateway_resource.notes.id
http_method = aws_api_gateway_method.list_notes.http_method
type = "AWS_PROXY"
integration_http_method = "POST"
uri = aws_lambda_function.notes_api.invoke_arn
}
# Methods - POST /notes
resource "aws_api_gateway_method" "create_note" {
rest_api_id = aws_api_gateway_rest_api.notes_api.id
resource_id = aws_api_gateway_resource.notes.id
http_method = "POST"
authorization = "NONE"
}
resource "aws_api_gateway_integration" "create_note_int" {
rest_api_id = aws_api_gateway_rest_api.notes_api.id
resource_id = aws_api_gateway_resource.notes.id
http_method = aws_api_gateway_method.create_note.http_method
type = "AWS_PROXY"
integration_http_method = "POST"
uri = aws_lambda_function.notes_api.invoke_arn
}
# Lambda permission for API Gateway
resource "aws_lambda_permission" "api_gw" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.notes_api.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.notes_api.execution_arn}/*/*"
}
# Deployment
resource "aws_api_gateway_deployment" "prod" {
depends_on = [
aws_api_gateway_integration.list_notes_int,
aws_api_gateway_integration.create_note_int
]
rest_api_id = aws_api_gateway_rest_api.notes_api.id
stage_name = "prod"
}
# Outputs
output "api_url" {
value = "${aws_api_gateway_deployment.prod.invoke_url}/notes"
}

Step 3: Package and Deploy

Terminal window
# Package Lambda code
cd lambda
zip ../lambda.zip index.py
cd ..
# Deploy
terraform init
terraform apply -auto-approve
# Test the API
API_URL=$(terraform output -raw api_url)
# Create a note
curl -X POST $API_URL \
-H "Content-Type: application/json" \
-d '{"title":"My Note","content":"Hello from Lambda!"}'
# List all notes
curl $API_URL
# Get single note
curl $API_URL/<id>
# Update a note
curl -X PUT $API_URL/<id> \
-H "Content-Type: application/json" \
-d '{"title":"Updated Note","content":"Updated content"}'
# Delete a note
curl -X DELETE $API_URL/<id>

Step 4: Clean Up

Terminal window
terraform destroy -auto-approve

Tip

Use AWS SAM CLI (sam local invoke) for local testing before deploying. SAM provides sam local start-api to test the full API Gateway integration locally.

Deliverables

  • Serverless REST API with full CRUD operations
  • DynamoDB table with on-demand billing
  • Lambda function with least-privilege IAM role
  • API Gateway with proxy integration and CORS headers
  • Working curl commands demonstrating all endpoints