Docker's image would run on AWS 24 as an ECS service on Fargate 24 . For a read-only API of two routes, Lambda 24 fits better anyway: nothing runs between requests, you pay per invocation and it scales itself, at the price of cold starts. CloudFormation 24 saves the inline ZipFile as index.js (CommonJS), and the runtime supplies the AWS SDK for JavaScript v3:
ApiFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: booknest-api
Runtime: nodejs24.x
Handler: index.handler
Role: !GetAtt ApiRole.Arn
MemorySize: 256
Timeout: 10
LoggingConfig: {LogGroup: !Ref ApiLogGroup}
Environment: {Variables: {BOOKS_TABLE: !Ref BooksTable}}
VpcConfig:
SubnetIds: !Split [",", !ImportValue booknest-web-private-subnets]
SecurityGroupIds: [!ImportValue booknest-web-api-sg]
Code:
ZipFile: |
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const { DynamoDBDocumentClient, GetCommand, ScanCommand } =
require("@aws-sdk/lib-dynamodb");
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TableName = process.env.BOOKS_TABLE;
const reply = (statusCode, body) => ({ statusCode, body: JSON.stringify(body),
headers: { "content-type": "application/json" } });
exports.handler = async (event) => {
const match = (event.rawPath ?? event.path).match(/^\/books(?:\/([^/]+))?$/);
if (!match) return reply(404, { error: "no such route" });
if (match[1] === undefined) {
const { Items } = await db.send(new ScanCommand({ TableName }));
return reply(200, Items.sort((a, b) => a.id - b.id));
}
if (!/^\d+$/.test(match[1])) return reply(400, { error: "id must be an integer" });
const { Item } = await db.send(
new GetCommand({ TableName, Key: { id: Number(match[1]) } }));
return Item ? reply(200, Item) : reply(404, { error: "book not found" });
};The handler reads rawPath (function URLs) or path (API Gateway 24 ). On 4.13.1 an API Gateway REST API (v1) with a proxy resource worked, and so did a function URL; an HTTP API (v2) is skipped silently (SAM's Transform). The function URL wins: fewer resources and no API Gateway charge, losing only API keys and usage plans:
ApiUrl:
Type: AWS::Lambda::Url
Properties:
TargetFunctionArn: !Ref ApiFunction
AuthType: NONE
Cors: {AllowOrigins: ["*"], AllowMethods: [GET]}
PublicUrlAccess:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref ApiFunction
Action: lambda:InvokeFunctionUrl
Principal: "*"
FunctionUrlAuthType: NONE
PublicInvokeViaUrl:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref ApiFunction
Action: lambda:InvokeFunction
Principal: "*"
InvokedViaFunctionUrl: trueAuthType: NONE alone opens nothing; the resource policy must let the public in. Since October 2025, new function URLs need both lambda:InvokeFunctionUrl and lambda:InvokeFunction, the second limited to calls made through the URL.