Build a Multi-Region Active-Active API on AWS with Global Accelerator and DynamoDB Global Tables
Run the same API in two regions, replicate state with DynamoDB Global Tables, and let Global Accelerator route traffic to whichever region is healthy and closest.
What you'll build
A REST-ish API running independently in two AWS regions (Lambda behind an ALB), backed by a DynamoDB Global Table that replicates writes between them. AWS Global Accelerator sits in front of both regions using static anycast IPs, sending each client to the nearest healthy region and failing over automatically within seconds if a region goes unhealthy.
Prerequisites
- AWS CLI v2, configured with credentials that have admin-ish access to IAM, Lambda, ELBv2, DynamoDB, and Global Accelerator.
- Node.js 20.x and npm locally, to build the Lambda deployment package.
- Two AWS regions with a default VPC. This tutorial uses
us-east-1andeu-west-1— swap in whatever pair makes sense for your users, as long as both support DynamoDB Global Tables (most commercial regions do). Confirm each region actually has a default VPC before you start:
aws ec2 describe-vpcs --region us-east-1 --filters Name=isDefault,Values=true --query 'Vpcs[0].VpcId'
If that comes back null, you'll need to create a VPC with public subnets yourself before step 4 — accounts created after late 2013 sometimes don't get a default VPC.
jqinstalled (optional, but handy for parsing CLI output).- Know that Global Accelerator's control-plane API only exists in
us-west-2. You'll pass--region us-west-2for everyaws globalacceleratorcall regardless of where your actual resources live — it's a global service, that's just where its API endpoint is. - Budget for cost: two ALBs running 24/7, Global Accelerator's hourly fee plus a data-transfer premium, and DynamoDB on-demand pricing. Nothing here is free-tier friendly if left running.
1. Create the DynamoDB Global Table
Create the table in your primary region with streams enabled (required for Global Tables), then add a replica in the second region.
TABLE=GlobalApiItems
aws dynamodb create-table \
--region us-east-1 \
--table-name "$TABLE" \
--attribute-definitions AttributeName=id,AttributeType=S \
--key-schema AttributeName=id,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES
aws dynamodb wait table-exists --region us-east-1 --table-name "$TABLE"
aws dynamodb update-table \
--region us-east-1 \
--table-name "$TABLE" \
--replica-updates '[{"Create":{"RegionName":"eu-west-1"}}]'
Use PAY_PER_REQUEST. With provisioned capacity you have to keep throughput settings synced across every replica manually, and mismatches throttle replication. Confirm both replicas are ACTIVE:
aws dynamodb describe-table --region eu-west-1 --table-name "$TABLE" \
--query 'Table.Replicas[].{Region:RegionName,Status:ReplicaStatus}'
DynamoDB Global Tables use last-writer-wins conflict resolution based on internal timestamps. There's no cross-region transaction — if the same item gets written in both regions within the replication window, one write silently loses. Fine for most APIs, not fine if you need strict conflict handling.
2. Write and package the Lambda function
mkdir multi-region-api && cd multi-region-api
npm init -y
npm pkg set type=module
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
index.js:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "crypto";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;
const REGION = process.env.AWS_REGION;
export const handler = async (event) => {
const path = event.path || "/";
const method = event.httpMethod || "GET";
if (path === "/health") return respond(200, { status: "ok", region: REGION });
if (path === "/items" && method === "POST") {
const body = JSON.parse(event.body || "{}");
const item = { id: body.id || randomUUID(), value: body.value, writtenInRegion: REGION };
await ddb.send(new PutCommand({ TableName: TABLE, Item: item }));
return respond(201, item);
}
if (path.startsWith("/items/") && method === "GET") {
const id = path.split("/")[2];
const result = await ddb.send(new GetCommand({ TableName: TABLE, Key: { id } }));
if (!result.Item) return respond(404, { message: "not found" });
return respond(200, { ...result.Item, servedFromRegion: REGION });
}
return respond(404, { message: "route not found" });
};
function respond(statusCode, obj) {
return {
statusCode,
statusDescription: `${statusCode} OK`,
isBase64Encoded: false,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(obj),
};
}
That response shape (statusCode, statusDescription, headers, body) is mandatory for a Lambda sitting behind an ALB target group — it's a different event/response contract than API Gateway proxy integration. Note that AWS_REGION is a reserved environment variable Lambda sets automatically at runtime; don't try to pass it yourself in --environment, it'll be rejected.
zip -r function.zip index.js node_modules package.json
3. Create the shared IAM execution role
One role, reused by both regional deployments (IAM is a global service):
aws iam create-role --role-name multi-region-api-role \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam attach-role-policy --role-name multi-region-api-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam put-role-policy --role-name multi-region-api-role \
--policy-name ddb-access \
--policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"dynamodb:GetItem\",\"dynamodb:PutItem\"],\"Resource\":\"arn:aws:dynamodb:*:*:table/$TABLE\"}]}"
ROLE_ARN=$(aws iam get-role --role-name multi-region-api-role --query 'Role.Arn' --output text)
sleep 15 # let the role propagate before Lambda tries to assume it
4. Deploy the stack to both regions
This loop creates the Lambda function, a default-VPC ALB, and a Lambda target group in each region, identically:
REGIONS=(us-east-1 eu-west-1)
FUNCTION=multi-region-api
for REGION in "${REGIONS[@]}"; do
aws lambda create-function --region "$REGION" \
--function-name "$FUNCTION" --runtime nodejs20.x --handler index.handler \
--role "$ROLE_ARN" --zip-file fileb://function.zip \
--environment "Variables={TABLE_NAME=$TABLE}" --timeout 10
VPC_ID=$(aws ec2 describe-vpcs --region "$REGION" --filters Name=isDefault,Values=true --query 'Vpcs[0].VpcId' --output text)
SUBNETS=$(aws ec2 describe-subnets --region "$REGION" --filters Name=vpc-id,Values=$VPC_ID --query 'Subnets[].SubnetId' --output text | tr '\t' ' ')
SG_ID=$(aws ec2 create-security-group --region "$REGION" --group-name multi-region-api-alb --description "alb sg" --vpc-id "$VPC_ID" --query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress --region "$REGION" --group-id "$SG_ID" --protocol tcp --port 80 --cidr 0.0.0.0/0
ALB_ARN=$(aws elbv2 create-load-balancer --region "$REGION" --name multi-region-api-alb \
--type application --scheme internet-facing --subnets $SUBNETS --security-groups "$SG_ID" \
--query 'LoadBalancers[0].LoadBalancerArn' --output text)
TG_ARN=$(aws elbv2 create-target-group --region "$REGION" --name multi-region-api-tg \
--target-type lambda --health-check-enabled --health-check-path /health \
--query 'TargetGroups[0].TargetGroupArn' --output text)
aws lambda add-permission --region "$REGION" --function-name "$FUNCTION" \
--statement-id elb-invoke --action lambda:InvokeFunction \
--principal elasticloadbalancing.amazonaws.com --source-arn "$TG_ARN"
FN_ARN=$(aws lambda get-function --region "$REGION" --function-name "$FUNCTION" --query 'Configuration.FunctionArn' --output text)
aws elbv2 register-targets --region "$REGION" --target-group-arn "$TG_ARN" --targets Id="$FN_ARN"
aws elbv2 create-listener --region "$REGION" --load-balancer-arn "$ALB_ARN" \
--protocol HTTP --port 80 --default-actions Type=forward,TargetGroupArn="$TG_ARN"
done
This tutorial uses plain HTTP to keep it copy-pasteable. For production, add an ACM certificate and an HTTPS listener in each region — Global Accelerator just passes TCP through, it doesn't touch TLS, so the cert lives on the ALB, not on the accelerator.
5. Wire up Global Accelerator
ACCEL_ARN=$(aws globalaccelerator create-accelerator --region us-west-2 \
--name multi-region-api --query 'Accelerator.AcceleratorArn' --output text)
LISTENER_ARN=$(aws globalaccelerator create-listener --region us-west-2 \
--accelerator-arn "$ACCEL_ARN" --protocol TCP --port-ranges FromPort=80,ToPort=80 \
--query 'Listener.ListenerArn' --output text)
for REGION in "${REGIONS[@]}"; do
ALB_ARN=$(aws elbv2 describe-load-balancers --region "$REGION" --names multi-region-api-alb \
--query 'LoadBalancers[0].LoadBalancerArn' --output text)
aws globalaccelerator create-endpoint-group --region us-west-2 \
--listener-arn "$LISTENER_ARN" --endpoint-group-region "$REGION" \
--health-check-protocol HTTP --health-check-path /health \
--health-check-interval-seconds 10 --threshold-count 3 \
--endpoint-configurations EndpointId="$ALB_ARN",Weight=128
done
aws globalaccelerator describe-accelerator --region us-west-2 \
--accelerator-arn "$ACCEL_ARN" --query 'Accelerator.DnsName' --output text
Don't skip --health-check-protocol HTTP. Global Accelerator's endpoint group health check defaults to plain TCP, and a TCP check only confirms the ALB is accepting connections on port 80 — it'll happily pass even if your Lambda is throwing 500s on every request. Setting the protocol to HTTP is what makes --health-check-path actually get evaluated.
The 10-second health check interval is the "fast" option (the alternative is 30s); combined with a threshold of 3, a failing region gets pulled out of rotation in roughly 30 seconds.
Verify it works
Grab the accelerator's DNS name from the last command and hit it a few times:
DNS=<accelerator-dns-name>
curl -s "http://$DNS/health"
You should get something like {"status":"ok","region":"us-east-1"} or {"status":"ok","region":"eu-west-1"}, depending on which region Global Accelerator's anycast network routed you to — that's determined by your actual network location, not round-robin, so don't be surprised if repeated calls from the same machine keep landing on the same region.
Now prove replication works:
curl -s -X POST "http://$DNS/items" -d '{"id":"abc123","value":"hello"}'
sleep 3
curl -s "http://$DNS/items/abc123"
If you want to confirm it actually replicated cross-region (rather than just re-reading from the same region), query DynamoDB directly in the region you didn't write to:
aws dynamodb get-item --region eu-west-1 --table-name "$TABLE" --key '{"id":{"S":"abc123"}}'
To test failover, deregister the target in one region (aws elbv2 deregister-targets --region <region> --target-group-arn <tg-arn> --targets Id=<fn-arn>) and keep curling — within about 30 seconds every request should come back from the surviving region.
Troubleshooting
InvalidParameterValueException: The role defined for the function cannot be assumed: IAM role propagation lag. Wait 15-20 seconds after creating the role and retry thecreate-functioncall.- Target group health checks stay unhealthy: check CloudWatch Logs for the Lambda first. A common cause is the function throwing on
/healthbecauseTABLE_NAMEisn't set, or thelambda:InvokeFunctionpermission's--source-arnnot exactly matching the target group ARN. Also double-check you set--health-check-protocol HTTPon the ALB target group and on the Global Accelerator endpoint group — without it, checks silently fall back to raw TCP and won't catch application-level failures. aws globalacceleratorcommands time out or 404: you forgot--region us-west-2. This is the one service where the region flag doesn't mean "where resources live."- Reads right after a write return stale or missing data: Global Table replication is asynchronous, typically well under a second but not guaranteed. Don't build read-after-write logic that assumes cross-region strong consistency — route a client back to the same region for that, or accept eventual consistency.
Next steps
Add HTTPS end to end: ACM certs on each regional ALB, then a Route 53 record aliasing your domain to the accelerator's static IPs. Move this from raw CLI into Terraform or AWS CDK (both have first-class Global Accelerator and Global Table constructs) before you add a third region or need repeatable deploys. Look at DynamoDB Global Tables' TransactGetItems/TransactWriteItems limitations across regions if your API needs stronger consistency guarantees than last-writer-wins. And if you're doing this for cost reasons rather than latency, compare against plain Route 53 latency-based routing with health checks — it's cheaper but fails over on DNS TTL timescales instead of Global Accelerator's near-instant network-level rerouting.
Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.
Discussion 1
i'm still not convinced global accelerator is worth the added complexity, especially when you consider the cost - has anyone done a thorough comparison of the benefits versus just using a good old fashioned cdn and some clever routing?