Build a Serverless REST API on AWS in 30 Minutes with Lambda, API Gateway, and DynamoDB
A hands-on walkthrough of building, securing, and testing a pay-per-request REST API with zero servers to manage. You'll get working Python code, a least-privilege IAM policy, and the cost and cleanup tips most tutorials skip.
Most "hello world" tutorials stop right before the interesting part. In this guide, you'll build a small but real REST API that stores and retrieves data, uses least-privilege permissions, and costs close to nothing when idle.
By the end you'll have:
POST /itemsto create an itemGET /items/{id}to fetch an item- No servers, no patching, and no capacity planning
Architecture
1
Client → API Gateway (HTTP API) → Lambda (Python) → DynamoDB| Service | Role |
|---|---|
| API Gateway (HTTP API) | Public HTTPS front door and routing |
| AWS Lambda | Runs your code on demand |
| Amazon DynamoDB | Serverless key-value / document storage |
Why an HTTP API instead of a REST API? HTTP APIs are cheaper and lower latency, and they cover most CRUD use cases. Choose the REST API type only if you need features like usage plans or request validation at the gateway.
Prerequisites
- An AWS account (the free tier covers everything here)
- Basic Python knowledge
- 30 minutes
Step 1: Create the DynamoDB table
- Open the DynamoDB console and choose Create table.
- Table name:
items - Partition key:
id(String) - Under table settings, choose Customize settings, then On-demand capacity.
- Choose Create table.
On-demand mode means you pay per request and never have to guess capacity.
Step 2: Create the Lambda function
- Open the Lambda console and choose Create function.
- Choose Author from scratch, name it
items-api, and select the Python 3.13 runtime (or the latest available). - Under Configuration → Environment variables, add
TABLE_NAME = items. - Replace the code with:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import json
import os
import uuid
import boto3
table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
def response(status, body):
return {
"statusCode": status,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
}
def lambda_handler(event, context):
route = event["routeKey"]
try:
if route == "POST /items":
data = json.loads(event.get("body") or "{}")
if "name" not in data:
return response(400, {"message": "'name' is required"})
item = {"id": str(uuid.uuid4()), "name": data["name"]}
table.put_item(Item=item)
return response(201, item)
if route == "GET /items/{id}":
item_id = event["pathParameters"]["id"]
result = table.get_item(Key={"id": item_id})
item = result.get("Item")
if not item:
return response(404, {"message": "Item not found"})
return response(200, item)
return response(404, {"message": "Route not found"})
except json.JSONDecodeError:
return response(400, {"message": "Invalid JSON body"})
except Exception as exc:
print(f"Unhandled error: {exc}") # goes to CloudWatch Logs
return response(500, {"message": "Internal server error"})- Choose Deploy.
Step 3: Grant least-privilege permissions
Your function can't touch DynamoDB yet. Open Configuration → Permissions, choose the execution role, and add this inline policy. Replace the Region and account ID with yours:
1
2
3
4
5
6
7
8
9
10
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
"Resource": "arn:aws:dynamodb:REGION:ACCOUNT_ID:table/items"
}
]
}Resist the urge to attach
AmazonDynamoDBFullAccess. Scoping to two actions on one table means a bug or a leaked credential can't touch anything else.Step 4: Create the HTTP API
- Open API Gateway and choose Create API → HTTP API → Build.
- Add an integration: Lambda, and select
items-api. - Add two routes pointing to that integration:
POST /itemsGET /items/{id}
- Keep the default stage (
$default) with auto-deploy enabled. - Create the API and copy the Invoke URL.
Step 5: Test it
Create an item:
1
2
3
curl -X POST https://YOUR_API_ID.execute-api.REGION.amazonaws.com/items \
-H "Content-Type: application/json" \
-d '{"name": "My first serverless item"}'Response:
1
{"id": "3f2b8c1e-...", "name": "My first serverless item"}Fetch it back with the returned ID:
1
curl https://YOUR_API_ID.execute-api.REGION.amazonaws.com/items/3f2b8c1e-...If something fails, check CloudWatch Logs for the
items-api function. That's where your print output and stack traces land.What does this cost?
At low traffic, almost nothing. You pay for:
- API Gateway: per million requests
- Lambda: per request plus compute time (with a generous monthly free tier)
- DynamoDB: per read/write request on on-demand tables
Check current pricing on each service's pricing page, since rates vary by Region and change over time. An idle API costs essentially nothing, which is the biggest advantage over an always-on server.
Production hardening checklist
This is a solid foundation, but before real users arrive, consider:
- [ ] Authentication: add a JWT authorizer (for example with Amazon Cognito) on your routes
- [ ] Throttling: set rate limits on the stage to protect against abuse and surprise bills
- [ ] Infrastructure as code: recreate this with AWS SAM, CDK, or Terraform so it's repeatable
- [ ] Observability: enable structured logging and set a CloudWatch alarm on Lambda errors
- [ ] Input validation: validate field types and lengths, not just presence
- [ ] Backups: enable point-in-time recovery on the DynamoDB table
Clean up
To avoid leftover resources, delete the HTTP API, the Lambda function (and its log group), and the DynamoDB table.
Wrap-up
In under 100 lines of code, you built an API that scales from zero to thousands of requests without you managing a single server. From here, try adding
PUT and DELETE routes, or put a frontend in front of it.If you build on this, I'd love to hear what you make. Share it in the comments below.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article