Skip to content
AWS projects 6 min read

Project: A Serverless REST API

In this project you build a working CRUD API (Create, Read, Update, Delete — the four basic operations a data API supports) without running a single server. The request flow is simple: a client calls an HTTP endpoint, Amazon API Gateway (AWS’s managed front door for APIs) receives it, hands it to an AWS Lambda function (code that runs on demand with no server to manage), and the function reads or writes a DynamoDB table (a fully managed NoSQL key-value database). You pay only when requests arrive, so an idle API costs almost nothing. This makes serverless a great fit for APIs with spiky or unpredictable traffic, internal tools, and side projects.

When to use serverless (and when not to)

Serverless shines when traffic is bursty, you want zero server maintenance, and you can tolerate occasional “cold starts” (a small delay the first time a function runs after being idle). It is not ideal for very steady high-volume workloads where a reserved container is cheaper, for requests that run longer than 15 minutes (Lambda’s hard limit), or when you need a persistent connection like a WebSocket-heavy game backend with custom protocols.

ApproachBest forWatch out for
Lambda + API GatewaySpiky traffic, low ops, pay-per-useCold starts, 15-min limit
Containers (ECS/Fargate)Steady traffic, long jobsAlways-on cost
EC2Full OS control, legacy appsYou patch and scale it

Step 1: Design the DynamoDB table around access patterns

The single most important serverless decision is your table’s keys. DynamoDB is fast and cheap only when you query by the partition key (the main lookup value) — adding indexes later to fix a bad design is painful and costs extra. Decide your access patterns first. For a simple “items” API the pattern is “get one item by its ID” and “list items,” so a single partition key itemId is enough.

Console steps:

  1. Open the DynamoDB console and choose Create table.
  2. Table name: Items. Partition key: itemId (type String).
  3. Leave On-demand capacity selected (it auto-scales and you pay per request).
  4. Choose Create table.

AWS CLI:

aws dynamodb create-table \
  --table-name Items \
  --attribute-definitions AttributeName=itemId,AttributeType=S \
  --key-schema AttributeName=itemId,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --region us-east-1

Output:

{
    "TableDescription": {
        "TableName": "Items",
        "TableStatus": "CREATING",
        "TableArn": "arn:aws:dynamodb:us-east-1:123456789012:table/Items"
    }
}

Cost note: On-demand DynamoDB charges about $1.25 per million writes and $0.25 per million reads, plus storage. A low-traffic API costs cents per month.

Step 2: Create a least-privilege execution role

Every Lambda function assumes an IAM role (an identity with permissions). The golden rule is least privilege: grant only the actions on only the resources the function needs — here, the Items table and CloudWatch Logs (where Lambda writes logs). Never attach a broad AmazonDynamoDBFullAccess policy.

Save this trust policy as trust.json (it lets Lambda assume the role):

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "lambda.amazonaws.com" },
    "Action": "sts:AssumeRole"
  }]
}

Save the permissions as policy.json, scoped to just this table and logs:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem","dynamodb:PutItem","dynamodb:DeleteItem","dynamodb:Scan"],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/Items"
    },
    {
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup","logs:CreateLogStream","logs:PutLogEvents"],
      "Resource": "arn:aws:logs:us-east-1:123456789012:*"
    }
  ]
}
aws iam create-role --role-name items-api-role \
  --assume-role-policy-document file://trust.json
aws iam put-role-policy --role-name items-api-role \
  --policy-name items-table-access \
  --policy-document file://policy.json

Step 3: Write the Lambda handler

With API Gateway proxy integration (the gateway passes the raw request to Lambda and returns whatever Lambda gives back), your function must return the exact shape { statusCode, headers, body }, where body is a string (use JSON.stringify). Forget this and clients get a confusing 502 error.

Save as index.mjs:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";

const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = "Items";
const reply = (statusCode, data) => ({
  statusCode,
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(data),
});

export const handler = async (event) => {
  const method = event.requestContext.http.method;
  const id = event.pathParameters?.id;
  if (method === "GET" && id) {
    const r = await db.send(new GetCommand({ TableName: TABLE, Key: { itemId: id } }));
    return r.Item ? reply(200, r.Item) : reply(404, { error: "Not found" });
  }
  if (method === "GET") {
    const r = await db.send(new ScanCommand({ TableName: TABLE }));
    return reply(200, r.Items);
  }
  if (method === "POST") {
    const item = JSON.parse(event.body);
    await db.send(new PutCommand({ TableName: TABLE, Item: item }));
    return reply(201, item);
  }
  if (method === "DELETE" && id) {
    await db.send(new DeleteCommand({ TableName: TABLE, Key: { itemId: id } }));
    return reply(204, {});
  }
  return reply(405, { error: "Method not allowed" });
};

Zip and deploy it:

zip function.zip index.mjs
aws lambda create-function --function-name items-api \
  --runtime nodejs22.x --handler index.handler \
  --zip-file fileb://function.zip \
  --role arn:aws:iam::123456789012:role/items-api-role

Step 4: Create the HTTP API and wire CORS

An HTTP API is the cheaper, faster API Gateway flavor (about $1.00 per million requests vs $3.50 for REST APIs). CORS (Cross-Origin Resource Sharing — the rule that lets a browser on one domain call your API on another) must be configured on the API itself, not inside your Lambda response. Many tutorials get this wrong and CORS silently fails.

CLI (one command creates and integrates everything):

aws apigatewayv2 create-api \
  --name items-http-api --protocol-type HTTP \
  --target arn:aws:lambda:us-east-1:123456789012:function:items-api \
  --cors-configuration AllowOrigins="*",AllowMethods="GET,POST,DELETE",AllowHeaders="content-type"

Output:

{
    "ApiEndpoint": "https://a1b2c3d4e5.execute-api.us-east-1.amazonaws.com",
    "ApiId": "a1b2c3d4e5",
    "Name": "items-http-api"
}

Then grant API Gateway permission to invoke the function:

aws lambda add-permission --function-name items-api \
  --statement-id apigw --action lambda:InvokeFunction \
  --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:us-east-1:123456789012:a1b2c3d4e5/*"

Optional: deploy with SAM instead

For repeatable deploys, the AWS Serverless Application Model (SAM) turns all of the above into one template. Save as template.yaml, then run sam build && sam deploy --guided:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
  ItemsApi:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      Runtime: nodejs22.x
      Policies:
        - DynamoDBCrudPolicy: { TableName: Items }
      Events:
        Api:
          Type: HttpApi

Step 5: Test the API

Use curl against the endpoint from the create-api output:

API=https://a1b2c3d4e5.execute-api.us-east-1.amazonaws.com
curl -X POST $API/items -d '{"itemId":"1","name":"Widget"}'
curl $API/items/1

Output:

{"itemId":"1","name":"Widget"}

Step 6: Clean up

Delete resources to avoid lingering (tiny) charges:

aws apigatewayv2 delete-api --api-id a1b2c3d4e5
aws lambda delete-function --function-name items-api
aws iam delete-role-policy --role-name items-api-role --policy-name items-table-access
aws iam delete-role --role-name items-api-role
aws dynamodb delete-table --table-name Items

Best Practices

  • Return the exact { statusCode, headers, body } proxy shape, with body as a JSON string.
  • Configure CORS on the API, not in your Lambda code, so preflight OPTIONS requests work.
  • Scope the Lambda execution role to its one table plus logs — never *FullAccess.
  • Design DynamoDB keys around your access patterns before writing code, not after.
  • Use On-demand capacity for unpredictable traffic to avoid over-provisioning cost.
  • Set a sensible Lambda timeout and memory size; both affect cost and cold-start time.
  • Prefer HTTP APIs over REST APIs unless you need features like request validation or usage plans.
Last updated June 15, 2026
Was this helpful?