Lambda over ECS

Choosing Lambda Over ECS for the API Layer

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:

infra/booknest-app.yaml: the API function, attached to the private subnetsYAML
  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:

infra/booknest-app.yaml: a public function URL and the two permissions it needsYAML
  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: true

AuthType: 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.