AWS for DevOps — Project 3: Serverless REST API

7 minute read

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

Next → Project 4: CloudFormation 3-Tier App

Updated: