AWS for DevOps — Project 3: Serverless REST API
Project Overview
Build a serverless REST API with API Gateway, Lambda functions, and DynamoDB. Add Cognito authentication to protect your endpoints. Zero servers to manage, scales automatically, and you pay only for what you use.
Real-World Analogy: Instead of building and maintaining your own restaurant (servers), you’re using a food truck (Lambda) that only appears when there are customers. No customers? No cost. Huge lunch rush? More food trucks appear automatically.
| Detail | Value |
|---|---|
| Difficulty | ⭐⭐⭐ (Advanced) |
| Time | 2-3 hours |
| Cost | ~$0 (within Free Tier) |
| Services | API Gateway, Lambda, DynamoDB, Cognito, IAM |
Architecture
┌──────────────────────────────────────────────────────────────┐
│ Serverless REST API │
│ │
│ Client → Cognito Auth → API Gateway → Lambda → DynamoDB │
│ │
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ │
│ │ Cognito │ │ API │ │ Lambda │ │ DynamoDB │ │
│ │ User Pool│──→│ Gateway │──→│Functions│──→│ Table │ │
│ │ │ │ (REST) │ │ │ │ │ │
│ └──────────┘ └──────────┘ └─────────┘ └──────────┘ │
│ │ │
│ Endpoints: │
│ GET /items │
│ POST /items │
│ GET /items/{id} │
│ PUT /items/{id} │
│ DELETE /items/{id} │
└──────────────────────────────────────────────────────────────┘
Step 1 — Create DynamoDB Table
# Create the Items table
$ aws dynamodb create-table \
--table-name Items \
--attribute-definitions \
AttributeName=id,AttributeType=S \
--key-schema \
AttributeName=id,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--tags Key=Project,Value=practice-3
{
"TableDescription": {
"TableName": "Items",
"TableStatus": "CREATING",
"KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}]
}
}
# Wait for table to be active
$ aws dynamodb wait table-exists --table-name Items
# Verify
$ aws dynamodb describe-table --table-name Items \
--query 'Table.{Name:TableName,Status:TableStatus,Mode:BillingModeSummary.BillingMode}'
{
"Name": "Items",
"Status": "ACTIVE",
"Mode": "PAY_PER_REQUEST"
}
Why PAY_PER_REQUEST? For unpredictable or bursty workloads, on-demand pricing means you never over-provision. You pay per request instead of for reserved capacity.
Step 2 — Create Lambda Execution Role
# Create the Lambda execution role
$ aws iam create-role \
--role-name lambda-api-role \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'
# Attach basic Lambda execution policy (CloudWatch Logs)
$ aws iam attach-role-policy \
--role-name lambda-api-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
# Add DynamoDB access
$ aws iam put-role-policy \
--role-name lambda-api-role \
--policy-name dynamodb-items-access \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Scan",
"dynamodb:Query"
],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/Items"
}]
}'
Step 3 — Create Lambda Functions
Create Item Function
create-item/index.mjs:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import { randomUUID } from 'crypto';
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
export const handler = async (event) => {
try {
const body = JSON.parse(event.body);
const item = {
id: randomUUID(),
name: body.name,
description: body.description || '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
await docClient.send(new PutCommand({
TableName: 'Items',
Item: item
}));
return {
statusCode: 201,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({ error: error.message })
};
}
};
List Items Function
list-items/index.mjs:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb';
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
export const handler = async () => {
try {
const result = await docClient.send(new ScanCommand({
TableName: 'Items'
}));
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: result.Items, count: result.Count })
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({ error: error.message })
};
}
};
Get / Update / Delete Functions
Follow the same pattern with GetCommand, UpdateCommand, and DeleteCommand respectively, extracting the id from event.pathParameters.id.
Deploy Functions
# Package and deploy create-item function
$ cd create-item && zip function.zip index.mjs && cd ..
$ aws lambda create-function \
--function-name create-item \
--runtime nodejs18.x \
--handler index.handler \
--role arn:aws:iam::123456789012:role/lambda-api-role \
--zip-file fileb://create-item/function.zip \
--timeout 10 \
--memory-size 128 \
--tags Project=practice-3
# Package and deploy list-items function
$ cd list-items && zip function.zip index.mjs && cd ..
$ aws lambda create-function \
--function-name list-items \
--runtime nodejs18.x \
--handler index.handler \
--role arn:aws:iam::123456789012:role/lambda-api-role \
--zip-file fileb://list-items/function.zip \
--timeout 10 \
--memory-size 128 \
--tags Project=practice-3
# Repeat for get-item, update-item, delete-item
Test Lambda Directly
$ aws lambda invoke \
--function-name list-items \
--payload '{}' \
output.json
$ cat output.json
{
"statusCode": 200,
"body": "{\"items\":[],\"count\":0}"
}
Step 4 — Create API Gateway
# Create REST API
$ aws apigateway create-rest-api \
--name "Items API" \
--description "Serverless Items CRUD API" \
--endpoint-configuration types=REGIONAL \
--tags Project=practice-3
{
"id": "abc123api",
"name": "Items API"
}
# Get root resource ID
$ aws apigateway get-resources --rest-api-id abc123api
{
"items": [{"id": "rootresource", "path": "/"}]
}
# Create /items resource
$ aws apigateway create-resource \
--rest-api-id abc123api \
--parent-id rootresource \
--path-part items
{
"id": "itemsresource"
}
# Create /items/{id} resource
$ aws apigateway create-resource \
--rest-api-id abc123api \
--parent-id itemsresource \
--path-part "{id}"
{
"id": "itemidresource"
}
Create Methods and Integrations
# GET /items → list-items Lambda
$ aws apigateway put-method \
--rest-api-id abc123api \
--resource-id itemsresource \
--http-method GET \
--authorization-type NONE
$ aws apigateway put-integration \
--rest-api-id abc123api \
--resource-id itemsresource \
--http-method GET \
--type AWS_PROXY \
--integration-http-method POST \
--uri arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:list-items/invocations
# POST /items → create-item Lambda
$ aws apigateway put-method \
--rest-api-id abc123api \
--resource-id itemsresource \
--http-method POST \
--authorization-type NONE
$ aws apigateway put-integration \
--rest-api-id abc123api \
--resource-id itemsresource \
--http-method POST \
--type AWS_PROXY \
--integration-http-method POST \
--uri arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:create-item/invocations
# Repeat for GET/PUT/DELETE on /items/{id}
Grant API Gateway Permission to Invoke Lambda
$ aws lambda add-permission \
--function-name create-item \
--statement-id apigateway-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:us-east-1:123456789012:abc123api/*/POST/items"
# Repeat for each function
Deploy the API
$ aws apigateway create-deployment \
--rest-api-id abc123api \
--stage-name prod \
--description "Initial deployment"
{
"id": "deploy123"
}
# API is now live at:
# https://abc123api.execute-api.us-east-1.amazonaws.com/prod
Step 5 — Add Cognito Authentication
# Create User Pool
$ aws cognito-idp create-user-pool \
--pool-name items-api-users \
--auto-verified-attributes email \
--policies '{
"PasswordPolicy": {
"MinimumLength": 8,
"RequireUppercase": true,
"RequireLowercase": true,
"RequireNumbers": true,
"RequireSymbols": false
}
}'
{
"UserPool": {
"Id": "us-east-1_AbC123",
"Name": "items-api-users"
}
}
# Create App Client
$ aws cognito-idp create-user-pool-client \
--user-pool-id us-east-1_AbC123 \
--client-name items-api-client \
--explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_REFRESH_TOKEN_AUTH
{
"UserPoolClient": {
"ClientId": "1a2b3c4d5e6f7g"
}
}
# Create API Gateway Authorizer
$ aws apigateway create-authorizer \
--rest-api-id abc123api \
--name cognito-auth \
--type COGNITO_USER_POOLS \
--provider-arns arn:aws:cognito-idp:us-east-1:123456789012:userpool/us-east-1_AbC123 \
--identity-source method.request.header.Authorization
{
"id": "auth123"
}
# Update methods to require Cognito auth
$ aws apigateway update-method \
--rest-api-id abc123api \
--resource-id itemsresource \
--http-method POST \
--patch-operations \
op=replace,path=/authorizationType,value=COGNITO_USER_POOLS \
op=replace,path=/authorizerId,value=auth123
Test with Authentication
# Sign up a user
$ aws cognito-idp sign-up \
--client-id 1a2b3c4d5e6f7g \
--username testuser@example.com \
--password MyP@ssw0rd
# Confirm the user (admin)
$ aws cognito-idp admin-confirm-sign-up \
--user-pool-id us-east-1_AbC123 \
--username testuser@example.com
# Get authentication token
$ aws cognito-idp initiate-auth \
--client-id 1a2b3c4d5e6f7g \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters USERNAME=testuser@example.com,PASSWORD=MyP@ssw0rd
{
"AuthenticationResult": {
"IdToken": "eyJraWQ...",
"AccessToken": "eyJraWQ...",
"RefreshToken": "eyJjdH..."
}
}
# Call API with token
$ curl -X POST \
https://abc123api.execute-api.us-east-1.amazonaws.com/prod/items \
-H "Authorization: eyJraWQ..." \
-H "Content-Type: application/json" \
-d '{"name": "Test Item", "description": "Created via API"}'
{
"id": "a1b2c3d4-...",
"name": "Test Item",
"description": "Created via API",
"createdAt": "2026-03-30T06:30:00.000Z"
}
Step 6 — Test the Full API
# List items
$ curl https://abc123api.execute-api.us-east-1.amazonaws.com/prod/items
{
"items": [
{
"id": "a1b2c3d4-...",
"name": "Test Item",
"description": "Created via API"
}
],
"count": 1
}
# Get single item
$ curl https://abc123api.execute-api.us-east-1.amazonaws.com/prod/items/a1b2c3d4-...
{
"id": "a1b2c3d4-...",
"name": "Test Item"
}
# Delete item
$ curl -X DELETE \
https://abc123api.execute-api.us-east-1.amazonaws.com/prod/items/a1b2c3d4-... \
-H "Authorization: eyJraWQ..."
{
"message": "Item deleted"
}
Step 7 — Clean Up
# Delete API Gateway
$ aws apigateway delete-rest-api --rest-api-id abc123api
# Delete Lambda functions
$ for fn in create-item list-items get-item update-item delete-item; do
aws lambda delete-function --function-name $fn
done
# Delete DynamoDB table
$ aws dynamodb delete-table --table-name Items
# Delete Cognito User Pool
$ aws cognito-idp delete-user-pool --user-pool-id us-east-1_AbC123
# Delete IAM role and policies
$ aws iam delete-role-policy --role-name lambda-api-role --policy-name dynamodb-items-access
$ aws iam detach-role-policy --role-name lambda-api-role --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
$ aws iam delete-role --role-name lambda-api-role
What You Built
| Component | Purpose |
|---|---|
| DynamoDB Table | NoSQL data store |
| 5 Lambda Functions | CRUD business logic |
| API Gateway | REST API endpoint |
| Cognito User Pool | Authentication |
| IAM Role | Lambda permissions |
API Endpoints Summary
| Method | Path | Auth | Lambda | Description |
|---|---|---|---|---|
| GET | /items | No | list-items | List all items |
| POST | /items | Yes | create-item | Create new item |
| GET | /items/{id} | No | get-item | Get single item |
| PUT | /items/{id} | Yes | update-item | Update item |
| DELETE | /items/{id} | Yes | delete-item | Delete item |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Missing Lambda permission | API Gateway can’t invoke Lambda | Add lambda:InvokeFunction permission |
| Wrong integration type | 502 Bad Gateway | Use AWS_PROXY for Lambda Proxy |
| No CORS headers | Browser requests fail | Add CORS to API Gateway responses |
| Cold starts | Slow first request | Use provisioned concurrency for latency-sensitive APIs |
| Scan instead of Query | Slow at scale | Use Query with proper key schema |