Building a Serverless REST API with AWS Lambda, DynamoDB & S3

Introduction

Serverless architecture lets you build and run applications without managing servers. AWS handles provisioning, scaling, and availability — you only write business logic. In this article, we walk through a concrete demo that implements a full CRUD API for a “Learning Path” resource using AWS Lambda, API Gateway, DynamoDB, and S3, all defined as code with the Serverless Framework and written in TypeScript.


Architecture Overview

                    ┌───────────────────────┐
  Client ────────>  │   API Gateway (HTTP)  │
                    └────────┬──────────────┘
                             │
              ┌──────────────┼───────────────────┐
              ▼              ▼                   ▼
        ┌──────────┐  ┌──────────┐       ┌──────────────┐
        │  Lambda  │  │  Lambda  │  ...  │    Lambda    │
        │  create  │  │   read   │       │  add media   │
        └────┬─────┘  └────┬─────┘       └───────┬──────┘
             │             │                     │
             ▼             ▼                     ▼
        ┌──────────────────────────┐     ┌───────────────┐
        │        DynamoDB          │     │      S3       │
        │   (learning-path table)  │     │ (media files) │
        └──────────────────────────┘     └───────────────┘

Each Lambda function handles a single HTTP route and is granted only the IAM permissions it needs — no shared roles, no over-privileged functions.


The API

The demo exposes a RESTful API for managing learning paths. A learning path is a simple resource with an ID, a name, and an optional media file reference.

Method Path Lambda Description
POST /learning-path create Create a new learning path
GET /learning-path/{id} read Retrieve a learning path by ID
PUT /learning-path/{id} update Update a learning path
DELETE /learning-path/{id} delete Delete a learning path and its media
POST /learning-path/{id}/media addMedia Upload a media file to a learning path

Project Structure

demo/
├── src/
│   ├── api/
│   │   └── learning-path/
│   │       ├── post.ts       (create handler)
│   │       ├── get.ts        (read handler)
│   │       ├── put.ts        (update handler)
│   │       ├── delete.ts     (delete handler)
│   │       └── media/
│   │           └── post.ts   (addMedia handler)
│   └── service/
│       └── learningPath.ts   (business logic)
├── serverless.yml            (infrastructure-as-code)
├── tsconfig.json
└── package.json

Handlers are thin: they extract input from the API Gateway event and delegate to LearningPathService. All business logic lives in the service layer.


Infrastructure as Code: serverless.yml

The entire AWS infrastructure is described in a single serverless.yml file. No clicking through the AWS Console — everything is version-controlled and reproducible.

Provider

provider:
  name: aws
  runtime: nodejs14.x
  region: eu-west-1

Functions

Each function declares its own trigger and IAM permissions:

functions:
  create:
    handler: src/api/learning-path/post.handler
    events:
      - http:
          method: POST
          path: /learning-path
    iamRoleStatements:
      - Effect: Allow
        Action: dynamodb:PutItem
        Resource: !GetAtt LearningPathTable.Arn

  addMedia:
    handler: src/api/learning-path/media/post.handler
    events:
      - http:
          method: POST
          path: /learning-path/{id}/media
    iamRoleStatements:
      - Effect: Allow
        Action:
          - dynamodb:GetItem
          - dynamodb:PutItem
        Resource: !GetAtt LearningPathTable.Arn
      - Effect: Allow
        Action: s3:PutObject
        Resource: !Sub "arn:aws:s3:::learning-path-media/*"

This is least-privilege IAM: the read function can only call GetItem, the delete function can only call DeleteItem and s3:DeleteObject, etc.

Resources

DynamoDB table and S3 bucket are declared in the same file:

resources:
  Resources:
    LearningPathTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: learning-path
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST

    MediaBucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: learning-path-media
        PublicAccessBlockConfiguration:
          BlockPublicAcls: true
          BlockPublicPolicy: true
          IgnorePublicAcls: true
          RestrictPublicBuckets: true

PAY_PER_REQUEST billing means you pay per DynamoDB operation — no capacity planning, no idle costs.


The Service Layer

LearningPathService encapsulates all interactions with AWS services:

// src/service/learningPath.ts
export class LearningPathService {
  private dynamo: DynamoDB.DocumentClient;
  private s3: S3;

  async post(data: object): Promise<LearningPath> {
    const item = { id: uuidv4(), ...data };
    await this.dynamo.put({ TableName: "learning-path", Item: item }).promise();
    return item;
  }

  async get(id: string): Promise<LearningPath> {
    const result = await this.dynamo.get({ TableName: "learning-path", Key: { id } }).promise();
    if (!result.Item) throw { statusCode: 404, message: "Not found" };
    return result.Item as LearningPath;
  }

  async addMedia(id: string, media: string): Promise<void> {
    const learningPath = await this.get(id);
    const mediaId = uuidv4();

    await this.s3.putObject({
      Bucket: "learning-path-media",
      Key: mediaId,
      Body: Buffer.from(media, "base64"),
      ContentType: "image/png",
    }).promise();

    await this.dynamo.put({
      TableName: "learning-path",
      Item: { ...learningPath, mediaId },
    }).promise();
  }

  async delete(id: string): Promise<void> {
    const learningPath = await this.get(id);

    if (learningPath.mediaId) {
      await this.s3.deleteObject({
        Bucket: "learning-path-media",
        Key: learningPath.mediaId,
      }).promise();
    }

    await this.dynamo.delete({ TableName: "learning-path", Key: { id } }).promise();
  }
}

Deleting a learning path also cleans up its associated S3 object — data integrity is enforced at the application level since DynamoDB has no foreign key constraints.


Handlers

Handlers are kept minimal — extract input, call service, return response:

// src/api/learning-path/get.ts
export const handler = async (event: APIGatewayEvent): Promise<APIGatewayProxyResult> => {
  const service = new LearningPathService();
  const learningPath = await service.get(event.pathParameters!.id!);

  return {
    statusCode: 200,
    body: JSON.stringify(learningPath),
  };
};

CI/CD: GitHub Actions

A GitHub Actions workflow automates deployment on every push, using OIDC federation between GitHub and AWS (no long-lived secrets stored in GitHub):

# .github/workflows/aws-deploy.yml
jobs:
  deploy:
    steps:
      - uses: actions/checkout@v2
      - uses: aws-actions/configure-aws-credentials@v1
        with:
          role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions-role
          aws-region: eu-west-1
      - run: npm install
      - run: npx serverless deploy

OIDC federation means GitHub Actions assumes an IAM role temporarily — no access keys, no secret rotation needed.


Local Development

The Serverless Framework supports local testing without deploying to AWS:

# Install dependencies
npm install

# Run API locally (emulates API Gateway + Lambda)
npx serverless offline

# Run with local DynamoDB
npx serverless offline --dynamoDbPort 8000

The serverless-offline plugin emulates API Gateway locally. The serverless-dynamodb-local plugin runs a local DynamoDB instance in a Docker container.


Key Patterns Illustrated

Pattern Implementation
Serverless compute AWS Lambda — no servers to manage
Infrastructure as Code All resources defined in serverless.yml
Least-privilege IAM serverless-iam-roles-per-function plugin
Pay-per-use storage DynamoDB PAY_PER_REQUEST billing
Media management S3 for binary objects, DynamoDB for metadata reference
OIDC-based CI/CD GitHub → AWS without long-lived credentials
Type safety TypeScript + @types/aws-lambda throughout

Deploying

# Install Serverless CLI
npm i -g serverless

# Configure AWS credentials
aws configure

# Install project dependencies
npm install

# Deploy to AWS (eu-west-1)
serverless deploy

# Remove all resources
serverless remove

After deployment, Serverless prints the API Gateway endpoint URLs:

endpoints:
  POST - https://abc123.execute-api.eu-west-1.amazonaws.com/dev/learning-path
  GET  - https://abc123.execute-api.eu-west-1.amazonaws.com/dev/learning-path/{id}
  ...

Conclusion

This demo shows how much you can build with very little infrastructure code. The key takeaways:

  • The Serverless Framework lets you define Lambda functions, API Gateway routes, DynamoDB tables, and S3 buckets in a single YAML file — no manual AWS Console setup
  • Per-function IAM roles enforce the principle of least privilege at the function level, limiting blast radius if a function is compromised
  • DynamoDB on-demand billing removes capacity planning entirely — pay only for what you use
  • S3 and DynamoDB complement each other: DynamoDB stores structured metadata, S3 stores binary objects; the application manages the relationship
  • OIDC federation for CI/CD eliminates the need to store long-lived AWS credentials as secrets
  • TypeScript brings type safety to Lambda handlers and service code, catching errors at compile time rather than at runtime in production