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
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
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"
Start LocalStack:
docker-compose up -d
Verify all services are running:
curl http://localhost:4566/_localstack/health
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 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
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'))
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)})
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)
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'])
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'])
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'})
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'})
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)
}
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)
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"}'
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
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
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}'
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
Response:
{"message": "Todo deleted successfully"}
Confirm Deletion
curl https://<your-api-id>.execute-api.localhost.localstack.cloud:4566/Prod/todos
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)