Building Serverless REST APIs with AWS Lambda and API Gateway: A Complete Tutorial

Introduction

Serverless computing has transformed how developers build and deploy applications in the cloud. By abstracting away server management, auto-scaling, and capacity planning, serverless architectures allow teams to focus purely on writing business logic. Among the most popular serverless services, AWS Lambda combined with Amazon API Gateway provides a powerful, scalable, and cost-effective foundation for building REST APIs.

In this tutorial, you’ll learn how to build, deploy, and manage a fully functional serverless REST API from scratch. We’ll cover everything from setting up your first Lambda function to implementing authentication, validation, and CI/CD pipelines — all using Infrastructure as Code principles.

Prerequisites: An AWS account (free tier eligible), AWS CLI configured with appropriate credentials, Node.js 18+ or Python 3.9+, and basic familiarity with REST API concepts.

Why Serverless for REST APIs?

Traditional REST APIs require provisioning EC2 instances, configuring load balancers, managing autoscaling groups, and handling OS patching. Serverless eliminates this overhead:

  • Zero scaling overhead: Lambda scales automatically from zero to thousands of concurrent executions
  • Pay-per-use pricing: You pay only for compute time consumed — no cost when your API is idle
  • Reduced operational burden: No servers to patch, SSH into, or monitor at the OS level
  • Built-in high availability: Lambda functions run across multiple Availability Zones by default
  • Ecosystem integration: Native integration with DynamoDB, SQS, SNS, S3, Cognito, and dozens of other AWS services

Architecture Overview

Our serverless REST API will follow this architecture:

Client (HTTP Request)
    │
    ▼
Amazon API Gateway (REST API)
    │
    ├── /items (GET, POST)
    ├── /items/{id} (GET, PUT, DELETE)
    │
    ▼
AWS Lambda Functions (Business Logic)
    │
    ▼
Amazon DynamoDB (Data Store)

API Gateway serves as the entry point, routing HTTP requests to individual Lambda functions. Each Lambda function performs CRUD operations against a DynamoDB table. This pattern is commonly called the “Lambda Service” architecture and is ideal for microservices.

Step 1: Creating the DynamoDB Table

Let’s start with the data layer. We’ll use DynamoDB — a fully managed NoSQL key-value and document database.

Using AWS CLI

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

Key decisions here:

  • PAY_PER_REQUEST (on-demand): Best for variable or unpredictable traffic. No capacity planning needed.
  • id as partition key: Using UUID strings as unique identifiers for each item.
  • No sort key needed: Each item has a unique ID; no need for hierarchical data storage.

Step 2: Writing the Lambda Function

We’ll write a single Lambda handler that routes requests based on the HTTP method and path. This is known as the “fat lambda” or “handler routing” pattern — suitable for small to medium APIs.

Python (3.9+) — lambda_function.py

import json
import os
import uuid
import boto3
from datetime import datetime

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ.get('TABLE_NAME', 'Items'))

def lambda_handler(event, context):
    print(f"Event: {json.dumps(event)}")
    
    http_method = event.get('httpMethod', '')
    resource = event.get('resource', '')
    path_params = event.get('pathParameters') or {}
    item_id = path_params.get('id')
    
    headers = {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type,Authorization'
    }
    
    try:
        if http_method == 'GET' and resource == '/items':
            return get_all_items(headers)
        elif http_method == 'GET' and resource == '/items/{id}':
            return get_item(item_id, headers)
        elif http_method == 'POST' and resource == '/items':
            body = json.loads(event.get('body', '{}'))
            return create_item(body, headers)
        elif http_method == 'PUT' and resource == '/items/{id}':
            body = json.loads(event.get('body', '{}'))
            return update_item(item_id, body, headers)
        elif http_method == 'DELETE' and resource == '/items/{id}':
            return delete_item(item_id, headers)
        elif http_method == 'OPTIONS':
            return build_response(200, {}, headers)
        else:
            return build_response(400, {'error': 'Unsupported route'}, headers)
    except Exception as e:
        print(f"Error: {str(e)}")
        return build_response(500, {'error': 'Internal server error'}, headers)

def get_all_items(headers):
    response = table.scan()
    return build_response(200, {'items': response.get('Items', [])}, headers)

def get_item(item_id, headers):
    if not item_id:
        return build_response(400, {'error': 'Item ID is required'}, headers)
    response = table.get_item(Key={'id': item_id})
    if 'Item' not in response:
        return build_response(404, {'error': 'Item not found'}, headers)
    return build_response(200, response['Item'], headers)

def create_item(body, headers):
    if 'name' not in body:
        return build_response(400, {'error': 'name is required'}, headers)
    
    item = {
        'id': str(uuid.uuid4()),
        'name': body['name'],
        'description': body.get('description', ''),
        'created_at': datetime.utcnow().isoformat(),
        'updated_at': datetime.utcnow().isoformat()
    }
    table.put_item(Item=item)
    return build_response(201, item, headers)

def update_item(item_id, body, headers):
    if not item_id:
        return build_response(400, {'error': 'Item ID is required'}, headers)
    
    update_expression = 'SET #name = :name, description = :desc, updated_at = :ts'
    expression_attrs = {
        '#name': 'name'
    }
    attr_values = {
        ':name': body.get('name'),
        ':desc': body.get('description', ''),
        ':ts': datetime.utcnow().isoformat()
    }
    
    response = table.update_item(
        Key={'id': item_id},
        UpdateExpression=update_expression,
        ExpressionAttributeNames=expression_attrs,
        ExpressionAttributeValues=attr_values,
        ReturnValues='ALL_NEW'
    )
    return build_response(200, response.get('Attributes', {}), headers)

def delete_item(item_id, headers):
    if not item_id:
        return build_response(400, {'error': 'Item ID is required'}, headers)
    table.delete_item(Key={'id': item_id})
    return build_response(204, {}, headers)

def build_response(status_code, body, headers):
    return {
        'statusCode': status_code,
        'headers': headers,
        'body': json.dumps(body) if status_code != 204 else ''
    }

Node.js 18+ — index.mjs

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, ScanCommand, GetCommand, PutCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import { v4 as uuidv4 } from 'uuid';

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME || 'Items';

const headers = {
  'Content-Type': 'application/json',
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type,Authorization',
};

export const handler = async (event) => {
  console.log('Event:', JSON.stringify(event));
  const { httpMethod, resource, pathParameters } = event;
  const itemId = pathParameters?.id;
  
  try {
    switch (`${httpMethod} ${resource}`) {
      case 'GET /items':
        return await getAllItems();
      case 'GET /items/{id}':
        return await getItem(itemId);
      case 'POST /items':
        return await createItem(JSON.parse(event.body || '{}'));
      case 'PUT /items/{id}':
        return await updateItem(itemId, JSON.parse(event.body || '{}'));
      case 'DELETE /items/{id}':
        return await deleteItem(itemId);
      case 'OPTIONS /items':
      case 'OPTIONS /items/{id}':
        return buildResponse(200, {});
      default:
        return buildResponse(400, { error: 'Unsupported route' });
    }
  } catch (error) {
    console.error('Error:', error);
    return buildResponse(500, { error: 'Internal server error' });
  }
};

async function getAllItems() {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  return buildResponse(200, { items: result.Items || [] });
}

async function getItem(id) {
  if (!id) return buildResponse(400, { error: 'Item ID is required' });
  const result = await docClient.send(new GetCommand({ TableName: TABLE_NAME, Key: { id } }));
  if (!result.Item) return buildResponse(404, { error: 'Item not found' });
  return buildResponse(200, result.Item);
}

async function createItem(body) {
  if (!body.name) return buildResponse(400, { error: 'name is required' });
  const now = new Date().toISOString();
  const item = {
    id: uuidv4(),
    name: body.name,
    description: body.description || '',
    createdAt: now,
    updatedAt: now,
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: item }));
  return buildResponse(201, item);
}

async function updateItem(id, body) {
  if (!id) return buildResponse(400, { error: 'Item ID is required' });
  const now = new Date().toISOString();
  const result = await docClient.send(new UpdateCommand({
    TableName: TABLE_NAME,
    Key: { id },
    UpdateExpression: 'SET #n = :name, description = :desc, updatedAt = :ts',
    ExpressionAttributeNames: { '#n': 'name' },
    ExpressionAttributeValues: { ':name': body.name, ':desc': body.description || '', ':ts': now },
    ReturnValues: 'ALL_NEW',
  }));
  return buildResponse(200, result.Attributes);
}

async function deleteItem(id) {
  if (!id) return buildResponse(400, { error: 'Item ID is required' });
  await docClient.send(new DeleteCommand({ TableName: TABLE_NAME, Key: { id } }));
  return buildResponse(204, {});
}

function buildResponse(statusCode, body) {
  return {
    statusCode,
    headers,
    body: statusCode === 204 ? '' : JSON.stringify(body),
  };
}

Tip: Always include CORS headers (Access-Control-Allow-Origin) in your Lambda response when the API is consumed by web browsers. For production, restrict the origin to your actual domain instead of using a wildcard.

Step 3: Packaging and Deploying the Lambda

Python Deployment Package

# Create a deployment package
mkdir -p build
pip install boto3 -t build/
cp lambda_function.py build/
cd build
zip -r ../function.zip .
cd ..
aws lambda create-function \
    --function-name items-api \
    --runtime python3.12 \
    --handler lambda_function.lambda_handler \
    --role arn:aws:iam::YOUR_ACCOUNT:role/lambda-dynamodb-role \
    --zip-file fileb://function.zip \
    --environment Variables={TABLE_NAME=Items} \
    --region us-east-1

Node.js Deployment Package

npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb uuid
zip -r function.zip index.mjs package.json node_modules/

aws lambda create-function \
    --function-name items-api \
    --runtime nodejs18.x \
    --handler index.handler \
    --role arn:aws:iam::YOUR_ACCOUNT:role/lambda-dynamodb-role \
    --zip-file fileb://function.zip \
    --environment Variables={TABLE_NAME=Items} \
    --region us-east-1

Step 4: IAM Role Configuration

The Lambda function needs an execution role with permissions to access DynamoDB. Create the trust policy and attach the required permissions:

# Create trust policy document for Lambda
cat > trust-policy.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

aws iam create-role \
    --role-name lambda-dynamodb-role \
    --assume-role-policy-document file://trust-policy.json

# Attach DynamoDB access policy
aws iam attach-role-policy \
    --role-name lambda-dynamodb-role \
    --policy-arn arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess

# Attach CloudWatch logs policy (essential for debugging)
aws iam attach-role-policy \
    --role-name lambda-dynamodb-role \
    --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

Security Note: For production, follow the principle of least privilege. Instead of AmazonDynamoDBFullAccess, create a custom policy scoped to specific table ARN and actions (GetItem, PutItem, UpdateItem, DeleteItem, Scan).

Step 5: Setting Up API Gateway

API Gateway acts as the HTTP front door. We'll create a REST API with resources and methods that map to our Lambda function.

Using AWS Console (Manual Setup)

While we'll automate everything eventually, understanding the manual process helps with debugging:

  1. Open API Gateway console → Create API → REST API (not HTTP API)
  2. Choose "New API" → Name it items-api → Regional endpoint
  3. Create resource: /items → Enable CORS
  4. Create resource: /items/{id} → Enable CORS
  5. For each resource, create methods: GET, POST, PUT, DELETE, OPTIONS
  6. Integrate each method with your Lambda function (Lambda Proxy integration = ON)
  7. Deploy API to a stage (e.g., prod)

Using AWS CLI (Automated)

# Create the REST API
API_ID=$(aws apigateway create-rest-api \
    --name "items-api" \
    --region us-east-1 \
    --query 'id' --output text)

# Get the root resource ID
ROOT_ID=$(aws apigateway get-resources \
    --rest-api-id $API_ID \
    --region us-east-1 \
    --query 'items[0].id' --output text)

# Create /items resource
ITEMS_ID=$(aws apigateway create-resource \
    --rest-api-id $API_ID \
    --parent-id $ROOT_ID \
    --path-part "items" \
    --region us-east-1 \
    --query 'id' --output text)

# Create /items/{id} resource
ITEM_ID=$(aws apigateway create-resource \
    --rest-api-id $API_ID \
    --parent-id $ITEMS_ID \
    --path-part "{id}" \
    --region us-east-1 \
    --query 'id' --output text)

# Create GET /items method with Lambda integration
aws apigateway put-method \
    --rest-api-id $API_ID \
    --resource-id $ITEMS_ID \
    --http-method GET \
    --authorization-type NONE \
    --region us-east-1

aws apigateway put-integration \
    --rest-api-id $API_ID \
    --resource-id $ITEMS_ID \
    --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:YOUR_ACCOUNT:function:items-api/invocations \
    --region us-east-1

# Deploy to prod
aws apigateway create-deployment \
    --rest-api-id $API_ID \
    --stage-name prod \
    --region us-east-1

echo "API endpoint: https://${API_ID}.execute-api.us-east-1.amazonaws.com/prod/items"

Step 6: Testing Your API

Once deployed, test each endpoint using curl or any HTTP client:

# Replace with your actual API endpoint
API_URL="https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/prod"

# Create an item (POST)
curl -X POST "$API_URL/items" \
  -H "Content-Type: application/json" \
  -d '{"name": "Serverless Guide", "description": "A comprehensive tutorial"}'

# Response:
# {"id":"abc-123","name":"Serverless Guide","description":"A comprehensive tutorial","created_at":"2025-01-15T10:30:00","updated_at":"2025-01-15T10:30:00"}

# Get all items (GET)
curl "$API_URL/items"

# Get a single item (GET)
curl "$API_URL/items/abc-123"

# Update an item (PUT)
curl -X PUT "$API_URL/items/abc-123" \
  -H "Content-Type: application/json" \
  -d '{"name": "Updated Guide", "description": "Updated description"}'

# Delete an item (DELETE)
curl -X DELETE "$API_URL/items/abc-123"

Step 7: Adding API Key Authentication

For production APIs, you'll want to restrict access. API Gateway supports several authentication mechanisms. Here's how to add API key authentication:

# Create an API key
aws apigateway create-api-key \
    --name "production-key" \
    --enabled \
    --region us-east-1 \
    --query 'value' --output text

# Create a usage plan
aws apigateway create-usage-plan \
    --name "basic-plan" \
    --description "1000 requests per month" \
    --api-stages "[{\"apiId\": \"$API_ID\", \"stage\": \"prod\"}]" \
    --throttle burstLimit=20,rateLimit=10 \
    --quota limit=1000,period=MONTH \
    --region us-east-1

# Associate key with plan
USAGE_PLAN_ID=$(aws apigateway get-usage-plans --query 'items[?name==`basic-plan`].id' --output text)
API_KEY_ID=$(aws apigateway get-api-keys --name-query "production-key" --query 'items[0].id' --output text)

aws apigateway create-usage-plan-key \
    --usage-plan-id $USAGE_PLAN_ID \
    --key-id $API_KEY_ID \
    --key-type API_KEY \
    --region us-east-1

# Now require API key on methods
aws apigateway update-method \
    --rest-api-id $API_ID \
    --resource-id $ITEMS_ID \
    --http-method GET \
    --patch-operations op=replace,path=/apiKeyRequired,value=true \
    --region us-east-1

# Deploy again (changes require redeployment)
aws apigateway create-deployment \
    --rest-api-id $API_ID \
    --stage-name prod \
    --region us-east-1

Clients must now include the API key in their requests:

curl -H "x-api-key: YOUR_API_KEY" "$API_URL/items"

Production Tip: API key authentication is the simplest option. For user-facing applications, consider Amazon Cognito User Pools for JWT-based authentication, or Lambda authorizers for custom auth logic.

Step 8: Observability with CloudWatch

Serverless doesn't mean "no ops." Monitoring is critical. AWS Lambda automatically emits metrics and logs to CloudWatch:

Key Metrics to Monitor

Metric What It Tells You Alert Threshold
Invocations Request volume over time Spike alerting for anomalies
Duration How long functions take to execute P99 > 5 seconds
Errors Failed invocations > 1% error rate
Throttles Concurrency limit exceeded Any throttle event
IteratorAge Stream-based invocations lag > 1 hour
# Create a CloudWatch alarm for error rate
aws cloudwatch put-metric-alarm \
    --alarm-name items-api-errors \
    --alarm-description "Alert when error count exceeds threshold" \
    --metric-name Errors \
    --namespace AWS/Lambda \
    --statistic Sum \
    --period 300 \
    --threshold 5 \
    --comparison-operator GreaterThanThreshold \
    --dimensions Name=FunctionName,Value=items-api \
    --evaluation-periods 2 \
    --alarm-actions arn:aws:sns:us-east-1:YOUR_ACCOUNT:ops-topic

Step 9: Automating with Infrastructure as Code

For repeatable, version-controlled deployments, use the Serverless Application Model (SAM) or Terraform. Here's a SAM template:

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless Items API

Globals:
  Function:
    Timeout: 10
    MemorySize: 128
    Runtime: python3.12
    Environment:
      Variables:
        TABLE_NAME: !Ref ItemsTable

Resources:
  ItemsApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Cors:
        AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization'"
        AllowOrigin: "'*'"

  ItemsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: lambda_function.lambda_handler
      Events:
        GetItems:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items
            Method: GET
        CreateItem:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items
            Method: POST
        GetItem:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items/{id}
            Method: GET
        UpdateItem:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items/{id}
            Method: PUT
        DeleteItem:
          Type: Api
          Properties:
            RestApiId: !Ref ItemsApi
            Path: /items/{id}
            Method: DELETE
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ItemsTable

  ItemsTable:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: id
        Type: String
      BillingMode: PAY_PER_REQUEST

Outputs:
  ApiEndpoint:
    Description: "API Gateway endpoint URL"
    Value: !Sub "https://${ItemsApi}.execute-api.${AWS::Region}.amazonaws.com/prod/items"

Deploy with:

sam build
sam deploy --guided  # First time only
sam deploy           # Subsequent deployments

Step 10: Cold Starts and Performance Optimization

A common criticism of serverless is the "cold start" — the latency when Lambda initializes a new execution environment. Here are proven strategies to mitigate this:

  • Provisioned Concurrency: Keep N execution environments warm for predictable latency. Adds cost but eliminates cold starts.
  • Reserve Concurrency: Prevents your function from being throttled during traffic spikes.
  • Choose faster runtimes: Python and Node.js have sub-second cold starts (~200-400ms). Java and .NET can take 2-6 seconds.
  • Reduce deployment package size: Smaller zips = faster initialization. Use Lambda Layers for shared dependencies.
  • Lazy-load connections: Initialize SDK clients and DB connections outside the handler (reused across warm invocations).
# Enable provisioned concurrency (AWS CLI)
aws lambda put-provisioned-concurrency-config \
    --function-name items-api \
    --qualifier prod \
    --provisioned-concurrent-executions 5 \
    --region us-east-1

Conclusion

You've now built a fully functional serverless REST API with AWS Lambda, API Gateway, and DynamoDB. The architecture we've implemented handles auto-scaling, pay-per-use pricing, and high availability without any server management.

Key takeaways from this tutorial:

  • Serverless REST APIs eliminate infrastructure management while providing enterprise-grade scalability
  • API Gateway + Lambda + DynamoDB forms a powerful "three-tier serverless" pattern
  • Always implement authentication, validation, and observability from day one
  • Use Infrastructure as Code (SAM, Terraform, CDK) for repeatable deployments
  • Optimize for cold starts with Provisioned Concurrency and efficient runtimes

In your next serverless project, consider adding AWS Step Functions for orchestration, EventBridge for event-driven integrations, and Lambda@Edge for running code at CloudFront edge locations. The serverless ecosystem is vast and continues to grow — but the fundamentals you've learned today will serve as a solid foundation for any serverless architecture.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top