π Serverless TODO App β Article Series
| Part | Title |
|---|---|
| 1 | Architecture Overview |
| 2 | Local Backend with AWS SAM (You are here) |
| 3 | Deploying Backend to AWS (SAM + CDK) - (Coming soonβ¦) |
ποΈ Part 2 β Running a Serverless API Locally with AWS SAM
(API Gateway + Lambda + DynamoDB Local)
This article expands on Part 1, diving into how the backend works and how to run it fully locally using:
- AWS SAM
- Local Lambda (Docker)
- DynamoDB Local
- Seed scripts
- curl-based testing
This ensures rapid, cost-free development before deploying to AWS (in Part 3).
π GitHub Repo:
π aws-sam-gateway-lambda-dynamodb
Scope: Part 2 is local-only. Cloud deploy, auth, and hardening land in Part 3.
π§° Prerequisites for Local-Only
- AWS SAM CLI
- Docker (for Lambda + DynamoDB Local containers)
- Python 3.13
- Optional: AWS CLI (not required for local-only; dummy creds work)
π― What This Part Covers
- How Lambda routing, validation, and DB access work together
- How SAM emulates API Gateway + Lambda
- How DynamoDB Local integrates via environment variables
- Full local run workflow
- curl commands to test every endpoint
π§ Why Local-First Development?
Local-first gives:
- Instant iteration
- No AWS costs
- Offline development
- Safe experimentation
- Faster debugging
- Confidence before cloud deployment
SAM provides a near-identical Lambda runtime locally using Docker.
ποΈ Architecture Overview & What Youβll Build
Single Lambda handles all /todos routes, talking to DynamoDB Local via SAMβs local API Gateway:
ββββββββββββββββββββββββββββ
β API Gateway (Local) β
ββββββββββββββββ¬ββββββββββββ
β
/todos routes
β
ββββββββββββββββΌββββββββββββ
β AWS Lambda (Python 3.13) β <-- src/app.py
β Router + Handlers (Local)β
ββββββββββββββββ¬ββββββββββββ
β boto3
ββββββββββββββββΌββββββββββββ
β DynamoDB (Local) β
ββββββββββββββββββββββββββββ
π§© What the Backend Does
CRUD API for TODO items:
| Method | Route | Description |
|---|---|---|
| GET | /todos | List all items |
| GET | /todos/{id} | Get by ID |
| POST | /todos | Create |
| PUT | /todos/{id} | Update |
| DELETE | /todos/{id} | Delete |
π§ Deep Dive β How the Lambda Works
Below are the essential excerpts needed to understand the backend logic.
1οΈβ£ Lambda Router (src/app.py)
def handler(event, context):
path = event.get("resource") or event.get("path", "")
http_method = event.get("httpMethod", "GET").upper()
path_params = event.get("pathParameters") or {}
if path == "/todos" and http_method == "POST":
return create_todo(event)
if path == "/todos" and http_method == "GET":
return list_todos(event)
if path == "/todos/{id}" and http_method == "GET":
return get_todo(path_params.get("id"))
if path == "/todos/{id}" and http_method == "PUT":
return update_todo(path_params.get("id"), event)
if path == "/todos/{id}" and http_method == "DELETE":
return delete_todo(path_params.get("id"))
return _response(404, {"message": "Not Found"})
β Simple but effective routing
β One Lambda for all endpoints
β Fast & cost-efficient
2οΈβ£ Pydantic Models (src/models.py)
class TodoCreate(BaseModel):
title: str = Field(min_length=1)
description: Optional[str] = None
status: str = Field(default="pending") # pending|done
class TodoUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
β Ensures clean validation
β Protects API from malformed payloads
3οΈβ£ DynamoDB Layer (src/ddb.py)
ddb = boto3.resource(
"dynamodb",
endpoint_url=os.getenv("DDB_ENDPOINT") # Local or AWS
)
table = ddb.Table(os.getenv("TABLE_NAME"))
This makes the DB layer environment-agnostic.
4οΈβ£ Example Handler β Create Todo
def create_todo(data):
payload = TodoCreate(**json.loads(data.get("body") or "{}"))
item = {
"id": str(uuid.uuid4()),
"title": payload.title,
"description": payload.description,
"status": payload.status,
"created_at": TodoItem.now_iso(),
"updated_at": TodoItem.now_iso(),
}
table.put_item(Item=item)
return _response(201, item)
β Validated input
β UUID generation
β Auto timestamps
5οΈβ£ SAM Template β What Each Part Does
Globals β shared settings for every Lambda: Python 3.13 runtime, 20s timeout, and env vars (
TABLE_NAME,DDB_ENDPOINT) so code can reados.environinstead of hardcoding values.TodoTable (DynamoDB) β a pay-per-request table named
${StackName}-todoswith partition keyidand astatus-indexGSI (Global Secondary Index). This is where all TODO items live.HttpApi (API Gateway) β creates the
/v1HTTP API with permissive CORS so you can call it from anywhere during dev.-
TodoFunction (Lambda)
-
CodeUri: src/,Handler: app.handlerβ entry point issrc/app.py::handler. -
Policies: DynamoDBCrudPolicyβ grants this Lambda CRUD access toTodoTable. -
Eventswire HTTP verbs/paths to the single function: -
POST /todos,GET /todos,GET /todos/{id},PUT /todos/{id},DELETE /todos/{id} -
DDB_ENDPOINTis empty in AWS (uses managed DynamoDB). For local runs, set it inenv_local.jsonto hit DynamoDB Local. -
sam local start-apireads these mappings so local routing mirrors production.
-
Outputs β surface the API URL and table name after deploy so you can copy/paste them into tests or clients.
πββοΈ Running the Backend Locally
1οΈβ£ Start DynamoDB Local
docker compose up -d
If you donβt have AWS creds configured, export dummy values so CLI + seed script work:
export AWS_ACCESS_KEY_ID=dummy
export AWS_SECRET_ACCESS_KEY=dummy
export AWS_REGION=ca-central-1
2οΈβ£ Create & Activate Python Virtual Environment using venv
macOS / Linux:
python3 -m venv venv
source venv/bin/activate
pip install -r src/requirements.txt
Windows (PowerShell):
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install -r src/requirements.txt
Deactivate any time:
deactivate
3οΈβ£ Seed Data
export TABLE_NAME=local-todos
export DDB_ENDPOINT=http://localhost:8000
python3 scripts/seed_local_ddb.py
4οΈβ£ Build the Project
sam build
5οΈβ£ Create env_local.json
{
"TodoFunction": {
"TABLE_NAME": "local-todos",
"DDB_ENDPOINT": "http://host.docker.internal:8000"
}
}
6οΈβ£ Start Local API Gateway
sam local start-api --env-vars env_local.json
# Add --debug to see detailed SAM logs while developing:
# sam local start-api --env-vars env_local.json --debug
Endpoint:
http://127.0.0.1:3000
7οΈβ£ Test Endpoints with curl (smoke test)
# Create
curl -sS -X POST http://127.0.0.1:3000/todos -H 'Content-Type: application/json' -d '{"title":"First Todo","description":"hello"}' | jq
# List
curl -sS http://127.0.0.1:3000/todos | jq
# Grab an id from the JSON above
ID="1a31c3fc-45b9-44f7-8152-47d258340d60"
# Get one
curl -sS http://127.0.0.1:3000/todos/$ID | jq
# Update
curl -sS -X PUT http://127.0.0.1:3000/todos/$ID -H 'Content-Type: application/json' -d '{"status":"done"}' | jq
# Delete
curl -i -sS -X DELETE http://127.0.0.1:3000/todos/$ID
Cloud next steps (covered in Part 3): sam deploy --guided with a real DynamoDB table (leave DDB_ENDPOINT empty).
8οΈβ£ Stop Local Services
- Stop SAM server: hit
Ctrl+Cin the terminal runningsam local start-api. - Stop DynamoDB Local:
docker compose down
π Additional Resources
GitHub Repository:
π aws-sam-gateway-lambda-dynamodb
AWS docs for deeper dives:
βοΈ Coming Up in Part 3
Part 3 covers:
- Deploying to AWS with SAM
- Deploying to AWS with CDK
- S3 packaging
- API Gateway
- DynamoDB
- AWS Free Tier vs Always-Free Notes
- Budget Alerts to avoid costs
Stay tuned! π
Top comments (0)