DEV Community

Cover image for 🌐 Building a multi-region Serverless API with the AWS CDK, Lambda, and DynamoDB
Thomas Aribart
Thomas Aribart

Posted on

🌐 Building a multi-region Serverless API with the AWS CDK, Lambda, and DynamoDB

While working on DynamoDB-Toolshack, I encountered a common challenge: ensuring fast and reliable performance for users worldwide. In today’s digital landscape, users expect snappy responses no matter their location. Meeting this demand often requires deploying globally distributed infrastructure.


For those unfamiliar, DynamoDB-Toolshack is the ultimate DynamoDB Studio for rapidly evolving applications. It elevates your DynamoDB experience with a schema-aware admin interface, data monitors and migration tools.

Discover DynamoDB-Toolshack


This guide walks you through building and deploying a multi-region serverless API using Amazon API Gateway, AWS Lambda, DynamoDB Global Tables, and the AWS CDK (Cloud Development Kit).


🧱 Architecture Overview

We’ll build the following architecture:

  • Amazon API Gateway – Public HTTP API to handle client requests.
  • AWS Lambda – Stateless compute service to process those requests.
  • Amazon DynamoDB Global Tables – A fully replicated database for low-latency data access across multiple regions.
  • AWS CDK – Infrastructure as code to define and deploy all resources.

Global Serverless API Architecture Diagram

Prerequisites

Before starting, make sure you:

  • Have a Route 53 hosted zone for a top-level domain (e.g., example.com), and plan to use a subdomain like api.example.com.
  • Write code in TypeScript.
  • Are familiar with the AWS CDK.

πŸ“¦ Step 1: Set Up Your CDK Project

Start by initializing a new CDK app:

# Create the folder
mkdir global-api-cdk && cd global-api-cdk
# Init the CDK App
npx cdk init app --language typescript
# Add some dependencies
npm add @types/aws-lambda @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
Enter fullscreen mode Exit fullscreen mode

🌍 Step 2: Choose Deployment Regions

Pick one primary region for global resources (e.g., us-east-1) and one or more additional regions for API and data replication.

// lib/regions.ts
export const globalResourcesRegion = 'us-east-1';
export const replicatedRegions = ['eu-west-1', 'sa-east-1'];

export const allRegions = [globalResourcesRegion, ...replicatedRegions];
Enter fullscreen mode Exit fullscreen mode

Each replicated region will host a replica of the API and DynamoDB table. The Global Table ensures data sync across all regions.


πŸ—οΈ Step 3: Define a DynamoDB Global Table

Create a GlobalResourcesStack containing unique resources. This includes a TableV2 with replicationRegions specified:

// lib/global.ts
import { Construct } from 'constructs';
import { Stack, StackProps } from 'aws-cdk-lib';
import { AttributeType, TableV2 } from 'aws-cdk-lib/aws-dynamodb';

import { replicatedRegions } from './regions';

export class GlobalResourcesStack extends Stack {
  table: TableV2;

  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);

    this.table = new TableV2(this, 'Table', {
      tableName: 'global-table',
      partitionKey: { name: 'id', type: AttributeType.STRING },
      replicas: replicatedRegions.map(region => ({ region })),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Create regional stacks for replicated resources. You can import the TableV2 with the fromTableName method:

// lib/local.ts
import { Construct } from 'constructs';
import { Stack, StackProps } from 'aws-cdk-lib';
import { ITableV2, TableV2 } from 'aws-cdk-lib/aws-dynamodb';

import { GlobalResourcesStack } from './global';

export class LocalResourcesStack extends Stack {
  // πŸ‘‡ Use ITableV2 type as the table is in the global stack
  table: ITableV2;

  constructor(
    scope: Construct,
    id: string,
    {
      globalResources,
      ...props
    }: { globalResources: GlobalResourcesStack } & StackProps,
  ) {
    super(scope, id, props);

    // πŸ‘‡ Use `fromTableName` to replace the stack region
    this.table = TableV2.fromTableName(
      this,
      'Table',
      globalResources.table.tableName,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, let's define the CDK App like this:

// bin/global-api-cdk.ts
import { App } from 'aws-cdk-lib';

import { allRegions, globalResourcesRegion } from '../lib/regions';
import { GlobalResourcesStack } from '../lib/global';
import { LocalResourcesStack } from '../lib/local';

const app = new App();

const globalResources = new GlobalResourcesStack(app, 'GlobalResources', {
  env: { region: globalResourcesRegion },
  // πŸ‘‡ Important to pass References from a stack to another
  crossRegionReferences: true,
});

for (const region of allRegions) {
  const localResources = new LocalResourcesStack(
    app,
    `LocalResources${region}`,
    {
      env: { region },
      globalResources,
      crossRegionReferences: region !== globalResourcesRegion,
    },
  );

  localResources.addDependency(globalResources);
}
Enter fullscreen mode Exit fullscreen mode

🌐 Step 4: Set Up Custom Domains

Let's import the top domain Hosted Zone (example.com) and create a Hosted Zone for the API subdomain (api.example.com). Hosted zones are global, so let's add it to the GlobalResourcesStack:

import {
  IHostedZone,
  HostedZone,
  RecordSet,
  RecordTarget,
  RecordType,
} from 'aws-cdk-lib/aws-route53';

class GlobalResourcesStack extends Stack {
  ...
  topDomainHostedZone: IHostedZone;
  apiSubDomainHostedZone: HostedZone;
  apiSubDomainDelegationRecords: RecordSet;

  constructor(...) {
    ...

    this.topDomainHostedZone = HostedZone.fromHostedZoneAttributes(
      this,
      'TopDomainHostedZone',
      // πŸ‘‡ Imported from your Top Domain configuration
      { zoneName: 'example.com', hostedZoneId: 'Z31867814UKQT2MAKXUTL' },
    );

    this.apiSubDomainHostedZone = new HostedZone(
      this,
      'ApiSubDomainHostedZone',
      { zoneName: 'api.example.com' },
    );

    // πŸ‘‡ (optional) To automatically append the delegation records
    this.apiSubDomainDelegationRecords = new RecordSet(
      this,
      'ApiSubDomainDelegationRecords',
      {
        zone: this.topDomainHostedZone,
        recordType: RecordType.NS,
        recordName: this.apiSubDomainHostedZone.zoneName,
        target: RecordTarget.fromValues(
          ...this.apiSubDomainHostedZone.hostedZoneNameServers!,
        ),
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, let's create a Rest API and a regional Certificate for each replicated region:

import { IHostedZone } from 'aws-cdk-lib/aws-route53';
import {
  Certificate,
  CertificateValidation,
} from 'aws-cdk-lib/aws-certificatemanager';
import { RestApi } from 'aws-cdk-lib/aws-apigateway';

class LocalResourcesStack extends Stack {
  ...
  // πŸ‘‡ Use IHostedZone type as the HostedZone is in the global stack
  apiSubDomainHostedZone: IHostedZone;
  apiCertificate: Certificate;
  api: RestApi;

  constructor(...) {
    ...
    this.apiSubDomainHostedZone = globalResources.apiSubDomainHostedZone;

    this.apiCertificate = new Certificate(this, 'ApiCertificate', {
      domainName: this.apiSubDomainHostedZone.zoneName,
      validation: CertificateValidation.fromDns(this.apiSubDomainHostedZone),
    });

    this.api = new RestApi(this, 'Api', {
      domainName: {
        domainName: this.apiSubDomainHostedZone.zoneName,
        certificate: this.apiCertificate,
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ›°οΈ Step 5: Add Latency-Based Routing

The CDK doesn’t natively support latency records, but we can use this workaround:

// lib/latencyRecord.ts
import { ARecordProps, CfnRecordSet, ARecord } from 'aws-cdk-lib/aws-route53';
import { Construct } from 'constructs';

export class LatencyRecord extends ARecord {
  constructor(
    scope: Construct,
    id: string,
    { region, ...props }: { region: string } & ARecordProps,
  ) {
    super(scope, id, props);

    const recordSet = this.node.defaultChild as CfnRecordSet;
    recordSet.region = region;
    recordSet.setIdentifier = region;
  }
}
Enter fullscreen mode Exit fullscreen mode

And use it like so:

import { ApiGateway } from 'aws-cdk-lib/aws-route53-targets';
import { RecordTarget } from 'aws-cdk-lib/aws-route53';

import { LatencyRecord } from './latencyRecord';

class LocalResourcesStack extends Stack {
  ...

  constructor(...) {
    ...

    new LatencyRecord(this, 'ApiSubDomainLatencyRecord', {
      zone: this.apiSubDomainHostedZone,
      target: RecordTarget.fromAlias(new ApiGateway(this.api)),
      region: this.region,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

βš™οΈ Step 6: Add a Lambda Function

import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda';
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
import { LambdaIntegration } from 'aws-cdk-lib/aws-apigateway';

class LocalResourcesStack extends Stack {
  ...
  lambda: NodejsFunction;

  constructor(...) {
    ...

    this.lambda = new NodejsFunction(this, 'Function', {
      entry: 'lib/handler.ts',
      handler: 'main',
      environment: {
        TABLE_NAME: this.table.tableName,
      },
      runtime: Runtime.NODEJS_20_X,
      architecture: Architecture.ARM_64,
      awsSdkConnectionReuse: true,
      logGroup: new LogGroup(this, 'FunctionLogGroup', {
        logGroupName: 'log-group',
        retention: RetentionDays.THREE_MONTHS,
      }),
      bundling: {
        minify: true,
        keepNames: true,
        sourceMap: true,
        mainFields: ['module', 'main'],
        externalModules: ['@aws-sdk/*'],
      },
    });

    // πŸ‘‡ Add permissions to read data from the table
    this.table.grantReadData(this.lambda);

    // πŸ‘‡ Set the API as Lambda trigger
    this.api.root
      .resourceForPath('/object/{id}')
      .addMethod('GET', new LambdaIntegration(this.lambda));
  }
}
Enter fullscreen mode Exit fullscreen mode

Here’s a simple code file for our handler:

// lib/handler.ts
import type {
  APIGatewayProxyEventBase,
  APIGatewayProxyResult,
} from 'aws-lambda';

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';

const dynamoDbClient = new DynamoDBClient();
const documentClient = DynamoDBDocumentClient.from(dynamoDbClient);

export const main = async (
  event: APIGatewayProxyEventBase<unknown>,
): Promise<APIGatewayProxyResult> => {
  const { pathParameters } = event;
  const id = pathParameters!.id;

  const { Item } = await documentClient.send(
    new GetCommand({ TableName: process.env.TABLE_NAME, Key: { id } }),
  );

  if (Item === undefined) {
    return { statusCode: 404, body: JSON.stringify({ message: 'Not found' }) };
  }

  return { statusCode: 200, body: JSON.stringify(Item) };
};
Enter fullscreen mode Exit fullscreen mode

πŸš€ Step 7: Deploy and Test

cdk bootstrap
cdk deploy --all
Enter fullscreen mode Exit fullscreen mode

Once deployed, test your endpoint at https://api.example.com/object/{id}. Depending on your position, you should observe CloudWatch Logs in one region or another!


πŸ’­ Final Thoughts

Adding a Cognito UserPool for authentication is easy, but beyond the scope of this guide.

This architecture has many benefits:

  • Global low-latency access to data.
  • Fully managed infrastructure with high availability.
  • Pay-per-use with minimal operational overhead.
  • Easily extendable to more regions or endpoints.

However, beware of:

  • Eventual consistency: DynamoDB Global Tables introduce replication delays between regions.
  • API Gateway limitations: Some features like API keys are region-specific and not globally synchronized.

πŸ› οΈ Conclusion

Deploying a global serverless API is simpler than ever with AWS’s managed services and the CDK. With just a few lines of code, you can serve users across the world with fast response times and consistent data. Whether you're building for mobile, IoT, or global web apps, this setup gives you performance, reliability, and scale.

Top comments (2)

Collapse
 
namesmt profile image
Trung Dang • Edited

Hi, amazing post!, I have a question:
I'm not yet familiar with AWS CDK and complex AWS integrations like this, on a quick thought, do you think it's possible to achieve the same outcome with even better latency and pricing by using Cloudfront to expose the Lambda instead of using API Gateway?

And also for Lambda streaming feature, which is only possible via Cloudfront.

Collapse
 
thomasaribart profile image
Thomas Aribart

Hi @namesmt !

The CDK is mostly a SDK in front of Cloudformation so if it's doable with Cloudformation it should be doable with the CDK.

As to using Cloudfront... You'll have to try, I'm not sure πŸ˜… One problem I see is that Cloudfront is a global service. One thing I did not mention is that latency based routing only works because the APIs are regional.

The other pattern that I've seen is to use a Cloudfront functions for dynamic routing. You can learn more in this article: medium.com/@d.kumarkaran20/dynamic...