Sometimes I need to run a long-lived app. In those cases I reach for AWS ECS Fargate instead of AWS Lambda. You can run a container on Fargate for as little as $9/month, or $2.70/month if you’re happy to roll the dice with Fargate Spot (I usually do!)

If you have a web app, you almost certainly use a load balancer in front of your containers. And this is where the cost goes from “fun side project” to “oh, I’m not sure I’m willing to spend that much money on this.” The load balancer by itself is at least $16.40/month - you could run six containers for that price!

No need for load balancers

You can forego the ALB entirely - and still get TLS termination and balancing load over multiple containers. You just need to use API Gateway HTTP API’s support for private integrations. These allow you to specify the origin behind the API Gateway as a HTTP endpoint inside a VPC, rather than the typical Lambda function ARN.

Instead of $16.40+/month you pay only $1 per million requests. For the traffic volumes that my hobby projects receive, that’s a huge saving.

Deployable example

Here’s a complete deployable example. There are two templates.

The first template is the base infrastructure. You would deploy this once into your account, and it can be shared across the many web apps you will deploy at example.com. It contains:

  • A VPC and its associated subnets, route tables, etc.
  • A Route 53 hosted zone for your DNS records
  • An ACM-managed TLS certificate (used by API Gateway later)
  • An API Gateway VPC Link and its security group. This is how API GW “reaches in” to the container running in your VPC.
  • A Cloud Map namespace.

The second template contains everything specific to a single application hosted on example.com. You would deploy multiple stacks from this template, one for each serverless app you have developed. It contains:

  • An ECS task definition
  • IAM roles for your ECS task definition
  • A CloudMap service. This holds the IP addresses of your running containers
  • An ECS service. This runs one copy of your task and registers/deregisters Fargate IPs with the CloudMap service when tasks start and stop.
  • A security group for your ECS service that only allows the VPC link to make requests to it.
  • An API gateway that forwards all requests to the CloudMap service via the VPC link.
  • An API Gateway API mapping and Route 53 record to make your API accessible at my-app.example.com.

Note that the ECS task definition contains a health check. This is because API Gateway itself doesn’t perform health checks like an ALB would - it’s up to you to tell ECS how it should check the health of your container. Here I have chosen to have ECS run curl inside the container.

# vpc-infra.yml
Resources:
  HostedZone:
    Type: AWS::Route53::HostedZone
    Properties:
      Name: example.com

  Certificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: example.com
      ValidationMethod: DNS
      SubjectAlternativeNames:
        - "*.example.com"
      DomainValidationOptions:
        - DomainName: example.com
          HostedZoneId: !Ref HostedZone

  CloudMapNamespace:
    Type: AWS::ServiceDiscovery::PrivateDnsNamespace
    Properties:
      Vpc: !Ref Vpc
      Name: example

  VpcLink:
    Type: AWS::ApiGatewayV2::VpcLink
    Properties:
      Name: vpclink
      SecurityGroupIds:
        - !Ref VpcLinkSecurityGroup
      SubnetIds:
        - !Ref SubnetA
        - !Ref SubnetB

  VpcLinkSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: vpc link
      VpcId: !Ref VpcId
      SecurityGroupIngress: []
      
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      EnableDnsHostnames: true
      EnableDnsSupport: true
      CidrBlock: 10.1.0.0/16

  SubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: 10.1.1.0/24
      AvailabilityZone: !Sub ${AWS::Region}a

  SubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: 10.1.2.0/24
      AvailabilityZone: !Sub ${AWS::Region}b

  InternetGateway:
    Type: AWS::EC2::InternetGateway

  GatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref Vpc
      InternetGatewayId: !Ref InternetGateway

  RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc

  InternetRoute:
    Type: AWS::EC2::Route
    DependsOn: GatewayAttachment
    Properties:
      GatewayId: !Ref InternetGateway
      RouteTableId: !Ref RouteTable
      DestinationCidrBlock: 0.0.0.0/0

  RouteTableAssociationSubnetA:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref SubnetA
      RouteTableId: !Ref RouteTable

  RouteTableAssociationSubnetB:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref SubnetB
      RouteTableId: !Ref RouteTable

Outputs:
  HostedZone:
    Value: !Ref HostedZone
    Export:
      Name: HostedZoneId
  Certificate:
    Value: !Ref Certificate
    Export:
      Name: Certificate
  CloudMapNamespace:
    Value: !Ref CloudMapNamespace
    Export:
      Name: CloudMapNamespace      
  VpcLink:
    Value: !Ref VpcLink
    Export:
      Name: VpcLink
  VpcLinkSecurityGroup:
    Value: !Ref VpcLinkSecurityGroup
    Export:
      Name: VpcLinkSecurityGroup
  Vpc:
    Value: !Ref Vpc
    Export:
      Name: VpcId
  SubnetA:
    Value: !Ref SubnetA
    Export:
      Name: SubnetA
  SubnetB:
    Value: !Ref SubnetB
    Export:
      Name: SubnetB  
# cheap-container-app.yml
Parameters:
  Image:
    Type: String
    Default: nginx
    
Resources:
  Service:
    Type: AWS::ECS::Service
    Properties:
      ServiceName: my-app
      TaskDefinition: !Ref TaskDefinition
      DesiredCount: 1
      ServiceRegistries:
        - RegistryArn: !GetAtt CloudMapService.Arn
          Port: 80
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED
          Subnets:
            - !ImportValue SubnetA
            - !ImportValue SubnetB
          SecurityGroups:
            - !Ref FargateSecurityGroup

  FargateSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: my app
      VpcId: !ImportValue VpcId
      SecurityGroupIngress:
        - SourceSecurityGroupId: !ImportValue VpcLinkSecurityGroup
          FromPort: 80
          ToPort: 80
          IpProtocol: tcp

  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: my-app
      Volumes: []
      Cpu: 256
      Memory: 512
      NetworkMode: awsvpc
      TaskRoleArn: !Ref TaskRole
      ExecutionRoleArn: !Ref ExecutionRole
      ContainerDefinitions:
        - Name: main
          Image: !Ref Image
          HealthCheck:
            Command:
              - CMD-SHELL
              - curl --fail http://127.0.0.1
          PortMappings:
            - ContainerPort: 80
              Protocol: tcp

  CloudMapService:
    Type: AWS::ServiceDiscovery::Service
    Properties:
      NamespaceId: !ImportValue CloudMapNamespace
      Name: my-app.example
      DnsConfig:
        DnsRecords:
          - Type: SRV
            TTL: 60
      HealthCheckCustomConfig:
        FailureThreshold: 1

  TaskRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service: [ecs-tasks.amazonaws.com]
            Action: sts:AssumeRole

  ExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Action: sts:AssumeRole
            Principal:
              Service: [ecs-tasks.amazonaws.com]
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

  ApiGateway:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      ProtocolType: HTTP
      Name: my-app

  Integration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref ApiGateway
      ConnectionId: !ImportValue VpcLink
      ConnectionType: VPC_LINK
      IntegrationMethod: ANY
      IntegrationType: HTTP_PROXY
      IntegrationUri: !GetAtt CloudMapService.Arn
      PayloadFormatVersion: 1.0

  Stage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      ApiId: !Ref ApiGateway
      StageName: $default
      AutoDeploy: true

  Route:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref ApiGateway
      RouteKey: $default
      Target: !Sub integrations/${Integration}

  GatewayDomain:
    Type: AWS::ApiGatewayV2::DomainName
    Properties:
      DomainName: my-app.example.com
      DomainNameConfigurations:
        - EndpointType: REGIONAL
          CertificateArn: !ImportValue Certificate

  GatewayMapping:
    Type: AWS::ApiGatewayV2::ApiMapping
    Properties:
      ApiId: !Ref ApiGateway
      DomainName: !Ref GatewayDomain
      Stage: !Ref Stage

  Record:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: !ImportValue HostedZoneId
      Name: my-app.example.com
      Type: A
      AliasTarget:
        DNSName: !GetAtt GatewayDomain.RegionalDomainName
        HostedZoneId: !GetAtt GatewayDomain.RegionalHostedZoneId

Thoughts

After writing out those templates, I get the feeling that this could be the kind of thing that would be a useful AWS CDK module, for the CDK-inclined. If anyone wants to build it, I’d be happy to update this post with a link to it.