DEV Community

Cover image for Building a Serverless TODO API with AWS SAM, Lambda, DynamoDB and LocalStack

Building a Serverless TODO API with AWS SAM, Lambda, DynamoDB and LocalStack

When I started learning serverless, my biggest fear wasn't the
technology, it was the AWS bill.

As a self-taught Cloud and DevOps Engineer still building my skills,
I needed a way to practice real AWS serverless architecture without
watching my account balance drop every time I deployed something.

The solution? LocalStack, a tool that runs AWS services locally
on your machine using Docker. Completely free. Completely real.

In this article I'll show you how I built a fully serverless
TODO REST API using AWS SAM, Lambda, API Gateway and DynamoDB,
all running locally for free using LocalStack.

What I Built

A fully serverless REST API that manages TODO items with
full CRUD operations.

POST /todos to create a new todo
GET /todos to retrieve all todos
GET /todos/{id} to retrieve a single todo
PUT /todos/{id} to update a todo
DELETE /todos/{id} to delete a todo

The entire stack is defined as infrastructure as code using
AWS SAM. No clicking around the console, no manual resource
creation. Just code, build and deploy.

Why Serverless?

No servers to manage, automatic scaling, and you pay only for
what you use. It is perfect for APIs with variable traffic.

And with LocalStack, you get all of that free during development.

What I Built

A fully serverless REST API that manages TODO items with
full CRUD operations.

POST /todos to create a new todo
GET /todos to retrieve all todos
GET /todos/{id} to retrieve a single todo
PUT /todos/{id} to update a todo
DELETE /todos/{id} to delete a todo

The entire stack is defined as infrastructure as code using
AWS SAM. No clicking around the console, no manual resource
creation. Just code, build and deploy.

The Architecture

AWS architecture diagram showing a client sending an HTTP request <br>
to API Gateway, which triggers a Lambda function that reads and <br>
writes to DynamoDB, with logs sent to CloudWatch.

The system is built on four AWS services that work together
as a single serverless application.

Amazon API Gateway receives incoming HTTP requests and routes
them to the appropriate Lambda function handler.

AWS Lambda runs the business logic. One function handles all
five API routes, reading the HTTP method and path from the
event object to determine what action to take.

Amazon DynamoDB stores the TODO items. Each item has a unique
ID generated at creation time, a title, description, completion
status and timestamp.

AWS SAM ties everything together as infrastructure as code.
The entire application, API Gateway, Lambda and DynamoDB, is
defined in a single template.yaml file and deployed with one command.

The request flow looks like this:

Client sends HTTP request
API Gateway receives and routes the request
Lambda function executes the business logic
DynamoDB stores or retrieves the data
Response returns to the client

Prerequisites

Before getting started, make sure you have the following
installed on your machine.

Docker to run LocalStack as a container.

Python 3.9 or higher as the runtime for the Lambda function.
For this project, I used Python 3.12, but any supported Python
version will work.

AWS SAM CLI to build and deploy the serverless application.

AWS CLI to configure credentials for LocalStack.

LocalStack to simulate AWS services locally.

You can verify your installations by running:

docker --version
python3 --version
sam --version
aws --version
localstack --version
Enter fullscreen mode Exit fullscreen mode

Setting Up LocalStack

LocalStack runs as a Docker container and simulates AWS services
locally. This is what allowed me to build and test this entire
project without spending anything on AWS.

First, create a docker-compose.yml file in your project folder:

version: "3"
services:
  localstack:
    image: localstack/localstack:latest
    container_name: localstack
    environment:
      - AWS_DEFAULT_REGION=us-east-1
      - EDGE_PORT=4566
      - SERVICES=lambda,s3,dynamodb,sns,events,apigateway,cloudwatch,cloudformation,iam,sts
    ports:
      - "4566:4566"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"
Enter fullscreen mode Exit fullscreen mode

Start LocalStack:

docker-compose up -d
Enter fullscreen mode Exit fullscreen mode

Verify all services are running:

curl http://localhost:4566/_localstack/health
Enter fullscreen mode Exit fullscreen mode

You should see apigateway, lambda, dynamodb, cloudformation
and iam all showing as available. That means LocalStack is
ready and we can start building.

Next, configure fake AWS credentials. LocalStack does not need
real credentials but the AWS CLI requires something to be set:

aws configure --profile localstack
Enter fullscreen mode Exit fullscreen mode

Enter dummy for the Access Key ID and Secret Access Key,
us-east-1 for the region and json for the output format.

Project Structure

After initializing the project with AWS SAM, the folder
structure looks like this:

todo-api/
├── todo_handler/
│ ├── init.py
│ ├── app.py
│ └── requirements.txt
├── events/
│ └── event.json
├── tests/
├── .gitignore
├── README.md
├── docker-compose.yml
└── template.yaml

todo_handler/app.py contains all the Lambda function code.
This is where the business logic lives.

template.yaml is the SAM template that defines all AWS
resources including API Gateway, Lambda and DynamoDB as
infrastructure as code.

docker-compose.yml starts LocalStack as a Docker container,
simulating the AWS services we need locally.

requirements.txt lists the Python dependencies for the
Lambda function.

The SAM Template

The template.yaml file is the heart of the project. It defines
all AWS resources as infrastructure as code. No manual resource
creation in the console.

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless TODO API using Lambda, API Gateway and DynamoDB

Globals:
  Function:
    Timeout: 10
    Runtime: python3.12
    Environment:
      Variables:
        TABLE_NAME: !Ref TodoTable
        AWS_ENDPOINT_URL: http://localstack:4566

Resources:

  TodoTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: todos
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH

  TodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: todo_handler/
      Handler: app.lambda_handler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref TodoTable
      Events:
        CreateTodo:
          Type: Api
          Properties:
            Path: /todos
            Method: post
        GetTodos:
          Type: Api
          Properties:
            Path: /todos
            Method: get
        GetTodo:
          Type: Api
          Properties:
            Path: /todos/{id}
            Method: get
        UpdateTodo:
          Type: Api
          Properties:
            Path: /todos/{id}
            Method: put
        DeleteTodo:
          Type: Api
          Properties:
            Path: /todos/{id}
            Method: delete
Enter fullscreen mode Exit fullscreen mode

Let me break down the key parts.

The Globals section sets default values for all Lambda functions
in the template. The timeout is set to 10 seconds and the runtime
is Python 3.12. The environment variables pass the DynamoDB table
name and the LocalStack endpoint URL directly into the function.

The TodoTable resource defines the DynamoDB table. I used
PAY_PER_REQUEST billing mode which means there is no cost
when the table is not being used. The partition key is id,
a string that uniquely identifies each todo item.

The TodoFunction resource defines the Lambda function. The
DynamoDBCrudPolicy automatically grants the function permission
to read and write to the DynamoDB table without manually
configuring IAM policies.

The Events section maps each HTTP route to the Lambda function.
SAM automatically creates the API Gateway and wires everything
together from this single configuration.

The Lambda Function

All the business logic lives in a single file, todo_handler/app.py.
Let me walk through each part.

The Imports and DynamoDB Connection

import json
import boto3
import os
import uuid
from datetime import datetime


dynamodb = boto3.resource('dynamodb', endpoint_url=os.environ.get('AWS_ENDPOINT_URL'))
table = dynamodb.Table(os.environ.get('TABLE_NAME', 'todos'))
Enter fullscreen mode Exit fullscreen mode

json handles parsing incoming request data and formatting responses.
boto3 is the AWS SDK for Python and is what connects the function to DynamoDB.
uuid generates a unique ID for each todo item at creation time.
datetime adds a timestamp when a todo is created.

The endpoint_url points to LocalStack during local development.
In production on real AWS, removing this line makes boto3
connect to the actual DynamoDB service automatically.

The lambda_handler

def lambda_handler(event, context):
    http_method = event['httpMethod']
    path_parameters = event.get('pathParameters') or {}

    try:
        if http_method == 'POST':
            return create_todo(event)
        elif http_method == 'GET' and 'id' not in path_parameters:
            return get_todos()
        elif http_method == 'GET' and 'id' in path_parameters:
            return get_todo(event)
        elif http_method == 'PUT':
            return update_todo(event)
        elif http_method == 'DELETE':
            return delete_todo(event)
        else:
            return response(404, {'message': 'Route not found'})
    except Exception as e:
        return response(500, {'message': str(e)})

Enter fullscreen mode Exit fullscreen mode

This is the entry point AWS calls every time an API request
comes in. Think of it as a traffic controller. It reads the
HTTP method and path parameters from the event object and
routes the request to the correct function.

The try/except block catches any unexpected errors and returns
a clean 500 response instead of crashing silently.

Create a Todo

def create_todo(event):
    body = json.loads(event['body'])
    if 'title' not in body:
        return response(400, {'message': 'title is required'})
    todo = {
        'id': str(uuid.uuid4()),
        'title': body['title'],
        'description': body.get('description', ''),
        'completed': False,
        'created_at': datetime.utcnow().isoformat()
    }
    table.put_item(Item=todo)
    return response(201, todo)
Enter fullscreen mode Exit fullscreen mode

This function parses the request body, validates that a title
was provided, builds the todo item with a unique ID and
timestamp, saves it to DynamoDB and returns the created item
with a 201 status code.

Get All Todos

def get_todos():
    result = table.scan()
    return response(200, result['Items'])
Enter fullscreen mode Exit fullscreen mode

This function scans the entire DynamoDB table and returns
all todo items. Simple and effective for a small dataset.

Get a Single Todo

def get_todo(event):
    todo_id = event['pathParameters']['id']
    result = table.get_item(Key={'id': todo_id})
    if 'Item' not in result:
        return response(404, {'message': 'Todo not found'})
    return response(200, result['Item'])
Enter fullscreen mode Exit fullscreen mode

This function extracts the ID from the path parameters,
queries DynamoDB for that specific item and returns a 404
if it does not exist.

Update a Todo

def update_todo(event):
    todo_id = event['pathParameters']['id']
    body = json.loads(event['body'])
    result = table.get_item(Key={'id': todo_id})
    if 'Item' not in result:
        return response(404, {'message': 'Todo not found'})
    table.update_item(
        Key={'id': todo_id},
        UpdateExpression='SET title = :title, description = :desc, completed = :completed',
        ExpressionAttributeValues={
            ':title': body.get('title', result['Item']['title']),
            ':desc': body.get('description', result['Item']['description']),
            ':completed': body.get('completed', result['Item']['completed'])
        }
    )
    return response(200, {'message': 'Todo updated successfully'})
Enter fullscreen mode Exit fullscreen mode

This function checks the item exists first, then updates only
the fields provided in the request body. If a field is not
provided, it keeps the existing value.

Delete a Todo

def delete_todo(event):
    todo_id = event['pathParameters']['id']
    result = table.get_item(Key={'id': todo_id})
    if 'Item' not in result:
        return response(404, {'message': 'Todo not found'})
    table.delete_item(Key={'id': todo_id})
    return response(200, {'message': 'Todo deleted successfully'})
Enter fullscreen mode Exit fullscreen mode

This function checks the item exists before attempting to
delete it and returns a clear success message when done.

The Response Helper

def response(status_code, body):
    return {
        'statusCode': status_code,
        'headers': {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
        },
        'body': json.dumps(body)
    }
Enter fullscreen mode Exit fullscreen mode

Every function uses this helper to return a properly formatted
HTTP response. Without this structure API Gateway would not
know how to send the result back to the client. The
Access-Control-Allow-Origin header allows the API to be
called from a browser.

What Went Wrong and How I Fixed It

No project goes perfectly the first time. Here are the two
bugs I hit during development and how I resolved them.

Bug 1: Route Not Found

After deploying and testing the CREATE endpoint I got this:

{"message": "Route not found"}

The issue was a trailing slash in my curl command. I was
sending the request to:

POST /todos/

Instead of:

POST /todos

A single extra slash was causing the routing to fail. Removing
it fixed the issue immediately. A small but important lesson
about how API Gateway handles URL matching.

Bug 2: Delete Was Not Deleting

After testing the DELETE endpoint I got this response:

{"message": "Todo deleted successfully"}

But when I ran GET /todos the item was still there.

The issue was in the original lambda_handler routing logic.
The condition for matching GET requests with a path parameter
was too broad and was also matching DELETE requests incorrectly,
causing the wrong function to be called.

I fixed it by simplifying the routing logic in lambda_handler
to check the HTTP method first and path parameters second:

if http_method == 'POST':
    return create_todo(event)
elif http_method == 'GET' and 'id' not in path_parameters:
    return get_todos()
elif http_method == 'GET' and 'id' in path_parameters:
    return get_todo(event)
elif http_method == 'PUT':
    return update_todo(event)
elif http_method == 'DELETE':
    return delete_todo(event)
Enter fullscreen mode Exit fullscreen mode

After redeploying with sam build and samlocal deploy the
delete worked perfectly and GET /todos returned an empty
list confirming the item was gone.

These two bugs taught me more about API Gateway routing
and Lambda event handling than any tutorial could.

Testing the API

I tested all five endpoints using curl. Here is the full
test sequence.

Create a Todo

curl -X POST https://<your-api-id>.execute-api.localhost.localstack.cloud:4566/Prod/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn AWS Lambda", "description": "Build serverless APIs"}'
Enter fullscreen mode Exit fullscreen mode

Response:

{
"id": "3d98c259-3239-4c9f-bac7-df23332c30bb",
"title": "Learn AWS Lambda",
"description": "Build serverless APIs",
"completed": false,
"created_at": "2026-03-13T09:55:34.339562"
}

Get All Todos

curl https://<your-api-id>.execute-api.localhost.localstack.cloud:4566/Prod/todos
Enter fullscreen mode Exit fullscreen mode

Response:

[
{
"id": "3d98c259-3239-4c9f-bac7-df23332c30bb",
"title": "Learn AWS Lambda",
"description": "Build serverless APIs",
"completed": false,
"created_at": "2026-03-13T09:55:34.339562"
}
]

Get a Single Todo

curl https://<your-api-id>.execute-api.localhost.localstack.cloud:4566/Prod/todos/3d98c259-3239-4c9f-bac7-df23332c30bb
Enter fullscreen mode Exit fullscreen mode

Response:

{
"id": "3d98c259-3239-4c9f-bac7-df23332c30bb",
"title": "Learn AWS Lambda",
"description": "Build serverless APIs",
"completed": false,
"created_at": "2026-03-13T09:55:34.339562"
}

Update a Todo

curl -X PUT https://<your-api-id>.execute-api.localhost.localstack.cloud:4566/Prod/todos/3d98c259-3239-4c9f-bac7-df23332c30bb \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn AWS Lambda", "description": "Build serverless APIs", "completed": true}'
Enter fullscreen mode Exit fullscreen mode

Response:

{"message": "Todo updated successfully"}

Delete a Todo

curl -X DELETE https://<your-api-id>.execute-api.localhost.localstack.cloud:4566/Prod/todos/3d98c259-3239-4c9f-bac7-df23332c30bb

Enter fullscreen mode Exit fullscreen mode

Response:

{"message": "Todo deleted successfully"}

Confirm Deletion

curl https://<your-api-id>.execute-api.localhost.localstack.cloud:4566/Prod/todos
Enter fullscreen mode Exit fullscreen mode

Response:

[]

All five endpoints working correctly. The empty array at
the end confirms the delete function is working as expected.

Possible Improvements

This project covers the core serverless API pattern but there
is a lot of room to extend it further.

Add authentication with Amazon Cognito to secure the API
endpoints and restrict access to authenticated users only.

Add input validation to check the request body more thoroughly
before saving to DynamoDB. For example validating that the
title is not empty or too long.

Add pagination to the GET /todos endpoint. As the number of
items grows, scanning the entire table becomes inefficient.
DynamoDB supports pagination natively.

Store an updated_at timestamp when a todo is updated so you
can track when items were last modified.

Add a CI/CD pipeline using GitHub Actions to automatically
build and deploy the application whenever code is pushed
to the main branch.

Deploy to real AWS once the application is stable and tested
locally. With SAM the same template.yaml works on both
LocalStack and real AWS with no changes needed.

Add unit tests for each Lambda function to catch bugs
before deployment.

Conclusion

Building this project taught me more than just how to write
Lambda functions. It taught me how serverless components
connect together as a complete system.

API Gateway handles routing. Lambda handles logic. DynamoDB
handles storage. SAM ties everything together as infrastructure
as code. Each service has a clear responsibility and they work
together seamlessly.

The biggest lesson was around local development. LocalStack
completely removed the cost barrier that was slowing down my
learning. I could build, break, fix and redeploy as many times
as I needed without worrying about AWS bills. That freedom
accelerated my learning significantly.

If you are learning serverless and struggling with AWS costs,
I highly recommend setting up LocalStack. The workflow of
developing locally and deploying to real AWS when ready is
a genuinely productive way to build.

The full source code for this project is available on GitHub:
https://github.com/Emmanuel-DevOps-Portfolio/serverless-todo-api

Business Impact for Local Startups

This architecture is not just a learning project. It solves
real business problems that many Nigerian startups face every day.

Cost Efficiency

Most Nigerian startups operate on tight budgets. Traditional
server-based architectures require paying for servers 24 hours
a day even when no one is using the application.

With serverless, you only pay when the API is actually called.
For a startup with low or unpredictable traffic, this can reduce
infrastructure costs by up to 80 percent compared to running
a dedicated server.

No DevOps Team Required

Small startups in Nigeria often cannot afford a dedicated
DevOps team. Serverless removes the need to manage, patch
and monitor servers. The infrastructure manages itself.

A small team of developers can build, deploy and maintain
a production API without any server administration knowledge.

Scales With Your Growth

Nigerian startups often experience sudden traffic spikes,
during product launches, media coverage or viral moments
on social media.

A serverless API scales automatically to handle thousands
of requests per second without any manual intervention.
You do not need to provision more servers in advance or
worry about your application going down under load.

Fast Time to Market

In a competitive startup environment, speed matters.
With AWS SAM, the entire infrastructure is defined as
code and can be deployed in minutes. A new developer
joining the team can have the full environment running
locally using LocalStack without needing access to
production AWS credentials.

Real World Use Cases

This exact architecture can power many products relevant
to the Nigerian market:

Logistics and delivery tracking APIs for last mile
delivery startups.

Fintech payment notification systems for digital
payment platforms.

Agritech farm monitoring APIs for agricultural
technology companies.

E-commerce order management systems for online
retail businesses.

Healthcare appointment booking APIs for digital
health platforms.

LocalStack for African Developers

The cost barrier of cloud learning is real across Africa.
LocalStack makes it possible to learn, build and test
production-grade AWS architectures completely free.

This levels the playing field for developers in Lagos,
Abuja, Accra, Nairobi and across the continent who are
building world class products on limited budgets.

I am Emmanuel Ulu, a Cloud and DevOps Engineer and AWS
Community Builder in the Serverless category. I share
hands-on projects as I explore serverless and event-driven
architectures on AWS.

If you found this article helpful, follow me here on dev.to
for more serverless content. And if you build something
using this project, I would love to hear about it in
the comments.

Top comments (0)