I Built an ECS Gateway and CloudFront VPC Origin Configuration Using CloudFormation and the CLI

I Built an ECS Gateway and CloudFront VPC Origin Configuration Using CloudFormation and the CLI

I built an ECS Gateway and CloudFront VPC origin setup with CloudFormation. For values that don't exist right after stack creation, such as the ECS Gateway's ALB ARN, I retrieved them via the CLI and passed them in as parameters.
2026.11.14

This page has been translated by machine translation. View original

Introduction

On October 20, 2026, Amazon ECS Gateway was announced.

https://aws.amazon.com/jp/about-aws/whats-new/2026/10/announcing-amazon-ecs-gateway/

ECS Gateway is a feature that creates a shared ALB with per-service hostnames, certificates, and listener rules. The steps for creating public and private gateways with the console and the CLI were covered in the previous article.

https://dev.classmethod.jp/articles/try-amazon-ecs-gateway/

This time, I build with CloudFormation a configuration that connects the internal ALB created by ECS Gateway to a CloudFront VPC origin to expose Nginx. The shared ALB is created by ECS Gateway at the point the first service is connected, so the ALB ARN cannot be referenced from the template. Therefore, I retrieved the ARN with the CLI and passed it in through CloudFormation parameters.

This article summarizes what could be written in the templates and where I supplemented with the CLI.

Architecture

I created seven stacks spanning the Tokyo region (ap-northeast-1) and us-east-1.

ECS Gateway と CloudFront VPC オリジンの構成図

Stack (Region) CloudFormation resource types created Values supplemented with the CLI
gwcf-network (ap-northeast-1) AWS::EC2::VPC, AWS::EC2::InternetGateway, AWS::EC2::VPCGatewayAttachment, AWS::EC2::Subnet, AWS::EC2::NatGateway, AWS::EC2::RouteTable, AWS::EC2::Route, AWS::EC2::SubnetRouteTableAssociation, AWS::EC2::VPCEndpoint, AWS::EC2::SecurityGroup None
gwcf-gateway (ap-northeast-1) AWS::IAM::Role, AWS::EC2::SecurityGroup, AWS::ECS::Cluster, AWS::ECS::Gateway, AWS::ECS::GatewayClusterAssociations CloudFront prefix list ID, ID of the VPC origin's managed security group (passed through an update after the VPC origin is created)
gwcf-nginx (ap-northeast-1) AWS::Logs::LogGroup, AWS::IAM::Role, AWS::ECS::TaskDefinition, AWS::ECS::Service None
gwcf-vpc-origin (us-east-1) AWS::CloudFront::VpcOrigin ALB ARN
gwcf-cloudfront (us-east-1) AWS::CloudFront::Distribution Hostname of serviceUrl
gwcf-alb-logs (ap-northeast-1) AWS::S3::Bucket, AWS::S3::BucketPolicy, AWS::Logs::DeliverySource, AWS::Logs::DeliveryDestination, AWS::Logs::Delivery ALB ARN
gwcf-cf-logs (us-east-1) The same five resource types as gwcf-alb-logs None
  • The gateway stack is configured in two stages. Before the VPC origin is created, the ALB's inbound source is the CloudFront managed prefix list; after creation, it is updated to the managed security group CloudFront-VPCOrigins-Service-SG that CloudFront creates. Using the managed security group restricts traffic to only that from your own CloudFront distribution.
  • The VPC origin, which needs the ALB ARN, is created after the Nginx service stack.
  • Access log delivery was added in separate stacks.

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-vpc-origins.html

What could not be written in CloudFormation

Resources created by ECS Gateway

At the point where the gateway has been created and associated with the cluster, the ALB does not exist yet. In the output of describe-gateway-resources, hostSharedAlb is empty.

{
    "gatewayResources": {
        "gatewayArn": "<GATEWAY_ARN>",
        "resources": {
            "hostSharedAlb": []
        }
    }
}

When the first service is connected, ECS Gateway creates the ALB, listener, target group, certificate, listener rule, and service security group. The ARNs of the ALB and the other resources cannot be obtained through GetAtt on AWS::ECS::Gateway or AWS::ECS::Service.

After connection, ecsManagedResources in describe-service-revisions returns the ARNs under the following keys. The ARNs of the ALB and the listener can also be obtained with describe-gateway-resources.

"acmCertificateArn": "<ACM_CERTIFICATE_ARN>",
"loadBalancerArn": "<ALB_ARN>",
"listenerArn": "<LISTENER_ARN>",
"listenerRuleArn": "<LISTENER_RULE_ARN>",
"targetGroupArn": "<TARGET_GROUP_ARN>"

Values retrieved with the CLI

There were four values that I retrieved with the CLI and passed in as parameters.

Value Destination Retrieval in CloudFormation
ALB ARN AlbArn in gwcf-vpc-origin and gwcf-alb-logs GetAtt on AWS::ECS::Gateway returns only AccessType, Arn, Configuration.HostSharedAlb.CurrentAlbCount, CreatedAt, Status, and UpdatedAt. GetAtt on AWS::ECS::Service returns only Name and ServiceArn
serviceUrl OriginDomainName in gwcf-cloudfront GetAtt on AWS::ECS::Service returns only Name and ServiceArn
ID of the VPC origin's managed security group VpcOriginSecurityGroupId in gwcf-gateway GetAtt on AWS::CloudFront::VpcOrigin returns only AccountId, Arn, CreatedTime, Id, LastModifiedTime, and Status
CloudFront prefix list ID CloudFrontPrefixListId in gwcf-gateway AWS-managed prefix list (OwnerId is AWS)

https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-ecs-gateway.html

https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-ecs-service.html

https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-cloudfront-vpcorigin.html

These are the commands used for retrieval.

# ALB ARN (loadBalancerArn in the output)
aws ecs describe-gateway-resources --region ap-northeast-1 --gateway gwcf-gateway --output json

# Service revision ARN (written based on the CLI help; the output was not saved)
aws ecs list-service-deployments --region ap-northeast-1 --service <SERVICE_ARN>

# serviceUrl
aws ecs describe-service-revisions --region ap-northeast-1 --service-revision-arns <SERVICE_REVISION_ARN> --query 'serviceRevisions[0].ecsManagedResources' --output json

# ID of the VPC origin's managed security group
aws ec2 describe-security-groups --region ap-northeast-1 --filters 'Name=group-name,Values=CloudFront-VPCOrigins-Service-SG*' --query 'SecurityGroups[].{name:GroupName,id:GroupId,desc:Description,vpc:VpcId,tags:Tags}' --output json

# CloudFront prefix list ID
aws ec2 describe-managed-prefix-lists --region ap-northeast-1 --filters 'Name=prefix-list-name,Values=com.amazonaws.global.cloudfront.origin-facing' --output json

These are the lines I used from the output of each command.

"loadBalancerArn": "<ALB_ARN>",
"serviceUrl": "https://gw-<32桁のID>.ecs.ap-northeast-1.on.aws",
"id": "<VPC_ORIGIN_SG_ID>",
"PrefixListId": "<PREFIX_LIST_ID>",

The revision ARN to pass to describe-service-revisions can be taken from the targetServiceRevisionArn returned by list-service-deployments.

serviceUrl is returned with https://, so pass the hostname without https:// as OriginDomainName. Filtering the managed security group by group name alone returned one result. In environments with multiple VPC origins, also narrow it down with Name=vpc-id,Values=<VPC_ID>.

Passing values between stacks

Values between stacks other than the four retrieved with the CLI were passed with Fn::GetStackOutput. By specifying Region, a stack in Tokyo can also reference the Outputs of a stack in us-east-1.

Referencing stack Stack with the Output Output
gwcf-gateway gwcf-network VpcId, PrivateSubnet1Id, PrivateSubnet2Id, PrivateSubnet3Id
gwcf-nginx gwcf-network PrivateSubnet1Id, PrivateSubnet2Id, PrivateSubnet3Id, TaskSecurityGroupId
gwcf-nginx gwcf-gateway ClusterName
gwcf-nginx gwcf-cloudfront (us-east-1) DistributionDomainName
gwcf-cloudfront gwcf-vpc-origin VpcOriginId
gwcf-cf-logs gwcf-cloudfront DistributionArn

gwcf-cloudfront needs the serviceUrl of the gwcf-nginx service. gwcf-nginx needs the domain of gwcf-cloudfront. The two stacks depend on each other. So I first created gwcf-nginx with the default value (a placeholder) of the BaseUrl parameter. After creating CloudFront, I updated it with UseCloudFrontDomain set to true so that it reads DistributionDomainName.

https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/intrinsic-function-reference-getstackoutput.html

Templates

There are seven template files, from 01-network.yaml to 07-cf-logs.yaml, which I deployed in the following order. The values in angle brackets are values retrieved with the CLI before running each command.

aws cloudformation deploy --region ap-northeast-1 --stack-name gwcf-network --template-file 01-network.yaml --no-fail-on-empty-changeset
aws cloudformation deploy --region ap-northeast-1 --stack-name gwcf-gateway --template-file 02-gateway.yaml --capabilities CAPABILITY_NAMED_IAM --no-fail-on-empty-changeset --parameter-overrides CloudFrontPrefixListId=<PREFIX_LIST_ID>
aws cloudformation deploy --region ap-northeast-1 --stack-name gwcf-nginx --template-file 03-nginx.yaml --capabilities CAPABILITY_NAMED_IAM --no-fail-on-empty-changeset
aws cloudformation deploy --region us-east-1 --stack-name gwcf-vpc-origin --template-file 04-vpc-origin.yaml --no-fail-on-empty-changeset --parameter-overrides AlbArn=<ALB_ARN>
aws cloudformation deploy --region us-east-1 --stack-name gwcf-cloudfront --template-file 05-cloudfront.yaml --no-fail-on-empty-changeset --parameter-overrides OriginDomainName=<SERVICE_HOST>
aws cloudformation deploy --region ap-northeast-1 --stack-name gwcf-alb-logs --template-file 06-alb-logs.yaml --no-fail-on-empty-changeset --parameter-overrides AlbArn=<ALB_ARN>
aws cloudformation deploy --region us-east-1 --stack-name gwcf-cf-logs --template-file 07-cf-logs.yaml --no-fail-on-empty-changeset

Network and gateway

This creates a VPC with three private subnets and an internal HTTPS (443) gateway. For the gateway's InboundSecurityGroup, I specify the security group created in the same stack. The condition is set so that when VpcOriginSecurityGroupId is empty, 443 from the prefix list is allowed, and when a value is passed, only 443 from the managed security group is allowed.

01-network.yaml and 02-gateway.yaml
01-network.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 1/7 network (ap-northeast-1). Minimal VPC for the ECS Gateway sample:
  3 private subnets, IGW, one regional NAT gateway, S3 gateway endpoint, and a security group for the tasks.

Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.20.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - { Key: Name, Value: gwcf-vpc }

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - { Key: Name, Value: gwcf-igw }

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

  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [0, !GetAZs ""]
      CidrBlock: 10.20.0.0/24
      Tags:
        - { Key: Name, Value: gwcf-private-1 }
  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [1, !GetAZs ""]
      CidrBlock: 10.20.1.0/24
      Tags:
        - { Key: Name, Value: gwcf-private-2 }
  PrivateSubnet3:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [2, !GetAZs ""]
      CidrBlock: 10.20.2.0/24
      Tags:
        - { Key: Name, Value: gwcf-private-3 }

  # Regional NAT gateway (auto AZ expansion). Only used so that the tasks can pull the public ECR image.
  NatGateway:
    Type: AWS::EC2::NatGateway
    DependsOn: InternetGatewayAttachment
    Properties:
      AvailabilityMode: regional
      ConnectivityType: public
      VpcId: !Ref Vpc
      Tags:
        - { Key: Name, Value: gwcf-natgw }

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags:
        - { Key: Name, Value: gwcf-private-rt }

  PrivateDefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGateway

  PrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1
      RouteTableId: !Ref PrivateRouteTable
  PrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet2
      RouteTableId: !Ref PrivateRouteTable
  PrivateSubnet3RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet3
      RouteTableId: !Ref PrivateRouteTable

  S3GatewayEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcEndpointType: Gateway
      VpcId: !Ref Vpc
      ServiceName: !Sub com.amazonaws.${AWS::Region}.s3
      RouteTableIds:
        - !Ref PrivateRouteTable

  # Security group of the Nginx tasks. No inbound rule here.
  TaskSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: gwcf-task-sg
      GroupDescription: gwcf Nginx tasks
      VpcId: !Ref Vpc
      Tags:
        - { Key: Name, Value: gwcf-task-sg }

Outputs:
  VpcId:
    Value: !Ref Vpc
  PrivateSubnet1Id:
    Value: !Ref PrivateSubnet1
  PrivateSubnet2Id:
    Value: !Ref PrivateSubnet2
  PrivateSubnet3Id:
    Value: !Ref PrivateSubnet3
  TaskSecurityGroupId:
    Value: !GetAtt TaskSecurityGroup.GroupId
02-gateway.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 2/7 gateway (ap-northeast-1). ECS Gateway (internal ALB, HTTPS 443), the ALB security group, the cluster and
  the gateway-cluster association. The ALB itself is created by ECS when the first service connects.

Parameters:
  CloudFrontPrefixListId:
    Type: String
    Description: ID of com.amazonaws.global.cloudfront.origin-facing in this Region (taken with the AWS CLI).
  VpcOriginSecurityGroupId:
    Type: String
    Default: ""
    Description: >-
      ID of CloudFront-VPCOrigins-Service-SG (taken with the AWS CLI after the VPC origin exists).
      Empty = allow the CloudFront managed prefix list. Set = allow only this security group.

  DeletionProtection:
    Type: String
    Default: "true"
    AllowedValues: ["true", "false"]
    Description: Deletion protection of the gateway. Set to "false" (stack update) before deleting the stack.

Conditions:
  UseVpcOriginSecurityGroup: !Not [!Equals [!Ref VpcOriginSecurityGroupId, ""]]

Resources:
  InfrastructureRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: gwcf-ecs-gateway-infra-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: ecs.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonECSInfrastructureRoleForGatewayHostSharedALB

  # Inbound security group of the ALB (ours, so the gateway creates no inbound rules on it).
  AlbSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: gwcf-gateway-alb-sg
      GroupDescription: gwcf ECS Gateway ALB inbound (HTTPS 443 from CloudFront)
      VpcId:
        Fn::GetStackOutput:
          StackName: gwcf-network
          OutputName: VpcId
          Region: ap-northeast-1
      SecurityGroupIngress:
        - !If
          - UseVpcOriginSecurityGroup
          - IpProtocol: tcp
            FromPort: 443
            ToPort: 443
            SourceSecurityGroupId: !Ref VpcOriginSecurityGroupId
            Description: HTTPS from the CloudFront VPC origin (managed SG)
          - !Ref AWS::NoValue
        - !If
          - UseVpcOriginSecurityGroup
          - !Ref AWS::NoValue
          - IpProtocol: tcp
            FromPort: 443
            ToPort: 443
            SourcePrefixListId: !Ref CloudFrontPrefixListId
            Description: HTTPS from the CloudFront managed prefix list
      Tags:
        - { Key: Name, Value: gwcf-gateway-alb-sg }

  Cluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: gwcf-cluster

  Gateway:
    Type: AWS::ECS::Gateway
    DependsOn: InfrastructureRole
    Properties:
      GatewayName: gwcf-gateway
      InfrastructureRoleArn: !GetAtt InfrastructureRole.Arn
      DeletionProtection: !Ref DeletionProtection
      PropagateTags: GATEWAY
      Configuration:
        HostSharedAlb:
          Scheme: internal
          Protocol: HTTPS
          Port: 443
          HttpDesync: defensive
          MaxAlbCount: 2
          NetworkConfiguration:
            IpAddressType: ipv4
            InboundSecurityGroup: !GetAtt AlbSecurityGroup.GroupId
            Subnets:
              - Fn::GetStackOutput:
                  StackName: gwcf-network
                  OutputName: PrivateSubnet1Id
                  Region: ap-northeast-1
              - Fn::GetStackOutput:
                  StackName: gwcf-network
                  OutputName: PrivateSubnet2Id
                  Region: ap-northeast-1
              - Fn::GetStackOutput:
                  StackName: gwcf-network
                  OutputName: PrivateSubnet3Id
                  Region: ap-northeast-1
      Tags:
        - { Key: Name, Value: gwcf-gateway }

  GatewayAssociation:
    Type: AWS::ECS::GatewayClusterAssociations
    Properties:
      Cluster: !Ref Cluster
      Gateway: !GetAtt Gateway.Arn

Outputs:
  GatewayArn:
    Value: !GetAtt Gateway.Arn
  ClusterName:
    Value: !Ref Cluster
  AlbSecurityGroupId:
    Value: !GetAtt AlbSecurityGroup.GroupId

Nginx service

Nginx was placed in three private subnets as a Fargate service. The container expands environment variables at startup to create the configuration file. As the response, it returns the service name, Host-related headers, and BASE_URL.

Specify PRIVATE for the gateway connector's accessTypes. When specified, the service connects only to internal gateways. If omitted, then if an internet-facing gateway is later associated with the cluster, the service will be exposed at the next deployment.

https://docs.aws.amazon.com/AmazonECS/latest/developerguide/gateway-connect-service.html

I did not write an inbound rule from the ALB in the task security group gwcf-task-sg. Even so, the deployment succeeded and the targets became healthy. This is because, in addition to gwcf-task-sg, the service security group created by ECS Gateway is also attached to the task's ENI. This security group allows port 80 from the security group that ECS Gateway attached to the ALB (separate from my own gwcf-gateway-alb-sg).

03-nginx.yaml
03-nginx.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 3/7 nginx (ap-northeast-1). Nginx Fargate service(s) connected to the internal ECS Gateway.
  Nginx answers with the host name it sees and the BASE_URL environment variable.

Parameters:
  BaseUrl:
    Type: String
    Default: https://example.invalid
    Description: Public URL of the app (the CloudFront domain). A placeholder at first, replaced after CloudFront exists.
  UseCloudFrontDomain:
    Type: String
    Default: "false"
    AllowedValues: ["true", "false"]
    Description: >-
      "true" = BASE_URL is https:// + the CloudFront domain, read from the cloudfront stack (us-east-1) with Fn::GetStackOutput.
      "false" = BASE_URL is the BaseUrl parameter.

Conditions:
  UseCloudFrontDomainCondition: !Equals [!Ref UseCloudFrontDomain, "true"]

Resources:
  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /ecs/gwcf-nginx
      RetentionInDays: 3

  TaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: gwcf-task-execution-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: ecs-tasks.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

  # ---------------------------------------------------------------- service 1
  TaskDefinition1:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: gwcf-nginx-1
      RequiresCompatibilities: [FARGATE]
      NetworkMode: awsvpc
      Cpu: "256"
      Memory: "512"
      RuntimePlatform:
        CpuArchitecture: X86_64
        OperatingSystemFamily: LINUX
      ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
      ContainerDefinitions:
        - Name: nginx
          Image: public.ecr.aws/docker/library/nginx:stable
          Essential: true
          PortMappings:
            - { Name: http, ContainerPort: 80, Protocol: tcp, AppProtocol: http }
          Environment:
            - { Name: SERVICE_NAME, Value: gwcf-nginx-1 }
            - Name: BASE_URL
              Value: !If
                - UseCloudFrontDomainCondition
                - !Join
                  - ""
                  - - https://
                    - Fn::GetStackOutput:
                        StackName: gwcf-cloudfront
                        OutputName: DistributionDomainName
                        Region: us-east-1
                - !Ref BaseUrl
          # The official image expands ${VAR} in /etc/nginx/templates/*.template at start-up.
          EntryPoint: [/bin/sh, -c]
          Command:
            - |
              mkdir -p /etc/nginx/templates
              cat > /etc/nginx/templates/default.conf.template <<'EOF'
              server {
                listen 80;
                location / {
                  default_type text/plain;
                  return 200 "service=${SERVICE_NAME}\nhost=$host\nhttp_host=$http_host\nx_forwarded_host=$http_x_forwarded_host\nx_forwarded_proto=$http_x_forwarded_proto\nx_forwarded_for=$http_x_forwarded_for\nBASE_URL=${BASE_URL}\n";
                }
              }
              EOF
              exec /docker-entrypoint.sh nginx -g 'daemon off;'
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: nginx1

  Service1:
    Type: AWS::ECS::Service
    Properties:
      ServiceName: gwcf-nginx-1
      Cluster:
        Fn::GetStackOutput:
          StackName: gwcf-gateway
          OutputName: ClusterName
          Region: ap-northeast-1
      TaskDefinition: !Ref TaskDefinition1
      LaunchType: FARGATE
      DesiredCount: 1
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: DISABLED
          SecurityGroups:
            - Fn::GetStackOutput:
                StackName: gwcf-network
                OutputName: TaskSecurityGroupId
                Region: ap-northeast-1
          Subnets:
            - Fn::GetStackOutput:
                StackName: gwcf-network
                OutputName: PrivateSubnet1Id
                Region: ap-northeast-1
            - Fn::GetStackOutput:
                StackName: gwcf-network
                OutputName: PrivateSubnet2Id
                Region: ap-northeast-1
            - Fn::GetStackOutput:
                StackName: gwcf-network
                OutputName: PrivateSubnet3Id
                Region: ap-northeast-1
      GatewayConnectors:
        - PortName: http
          Filters:
            AccessTypes: [PRIVATE]
          HealthCheck:
            HealthCheckPath: /

Outputs:
  Service1Name:
    Value: !GetAtt Service1.Name

VPC origin and CloudFront

04-vpc-origin.yaml receives the ALB ARN as a parameter.

04-vpc-origin.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 4/7 vpc-origin (us-east-1). CloudFront VPC origin that points at the internal ALB of the ECS Gateway.
  The ALB ARN is not a CloudFormation attribute of the gateway or the service, so it is passed as a parameter.

Parameters:
  AlbArn:
    Type: String
    Description: ARN of the ALB created by the ECS Gateway (taken with `aws ecs describe-gateway-resources`).

Resources:
  VpcOrigin:
    Type: AWS::CloudFront::VpcOrigin
    Properties:
      VpcOriginEndpointConfig:
        Name: gwcf-alb
        Arn: !Ref AlbArn
        HTTPPort: 80
        HTTPSPort: 443
        OriginProtocolPolicy: https-only
      Tags:
        - { Key: Name, Value: gwcf-vpc-origin }

Outputs:
  VpcOriginId:
    Value: !Ref VpcOrigin

05-cloudfront.yaml receives the origin domain name as a parameter and the VPC origin ID through Fn::GetStackOutput. For the cache policy, I specified Managed-CachingDisabled, which does not cache. For the origin request policy, I specified Managed-AllViewerExceptHostHeader, which does not forward Host.

05-cloudfront.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 5/7 cloudfront (us-east-1). CloudFront distribution whose origin is the ECS Gateway service host name
  (reached through the VPC origin). The Host header is not forwarded; nothing is cached. No log settings here.

Parameters:
  OriginDomainName:
    Type: String
    Description: Host name of serviceUrl without https:// (taken with `aws ecs describe-service-revisions`).
  OriginRequestPolicyId:
    Type: String
    # Managed-AllViewerExceptHostHeader (Host is not forwarded).
    Default: b689b0a8-53d0-40ab-baf2-68738e2966ac

Resources:
  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Comment: gwcf sample
        HttpVersion: http2
        IPV6Enabled: false
        PriceClass: PriceClass_100
        Origins:
          - Id: gateway-alb
            DomainName: !Ref OriginDomainName
            VpcOriginConfig:
              VpcOriginId:
                Fn::GetStackOutput:
                  StackName: gwcf-vpc-origin
                  OutputName: VpcOriginId
                  Region: us-east-1
        DefaultCacheBehavior:
          TargetOriginId: gateway-alb
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods: [GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE]
          CachedMethods: [GET, HEAD]
          Compress: true
          # Managed-CachingDisabled
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
          OriginRequestPolicyId: !Ref OriginRequestPolicyId
      Tags:
        - { Key: Name, Value: gwcf-cloudfront }

Outputs:
  DistributionId:
    Value: !Ref Distribution
  DistributionDomainName:
    Value: !GetAtt Distribution.DomainName
  DistributionArn:
    Value: !Sub arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}

Access log delivery

The ALB and CloudFront access logs are each delivered to S3 by separate stacks. For the ResourceArn of the DeliverySource, I pass the ARN retrieved with the CLI for the ALB, and the DistributionArn of gwcf-cloudfront retrieved with Fn::GetStackOutput for CloudFront.

06-alb-logs.yaml
06-alb-logs.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 6/7 alb-logs (ap-northeast-1). ALB access logs v2 (CloudWatch Logs vended logs delivery) to S3.
  Added after the gateway exists; the ALB attributes (access_logs.s3.*) are not touched.

Parameters:
  AlbArn:
    Type: String
    Description: ARN of the ALB created by the ECS Gateway.

Resources:
  LogBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete
    Properties:
      BucketName: !Sub gwcf-alb-logs-${AWS::AccountId}-${AWS::Region}
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerEnforced
      LifecycleConfiguration:
        Rules:
          - Id: expire-logs
            Status: Enabled
            ExpirationInDays: 7

  LogBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref LogBucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowLogDeliveryPutObject
            Effect: Allow
            Principal: { Service: delivery.logs.amazonaws.com }
            Action: s3:PutObject
            Resource: !Sub ${LogBucket.Arn}/*
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              ArnLike:
                aws:SourceArn: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:delivery-source:*
          - Sid: AllowLogDeliveryGetBucketAcl
            Effect: Allow
            Principal: { Service: delivery.logs.amazonaws.com }
            Action: s3:GetBucketAcl
            Resource: !GetAtt LogBucket.Arn
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              ArnLike:
                aws:SourceArn: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:delivery-source:*

  DeliverySource:
    Type: AWS::Logs::DeliverySource
    Properties:
      Name: gwcf-alb-access-logs
      ResourceArn: !Ref AlbArn
      LogType: ALB_ACCESS_LOGS

  DeliveryDestination:
    Type: AWS::Logs::DeliveryDestination
    DependsOn: LogBucketPolicy
    Properties:
      Name: gwcf-alb-logs-s3-json
      DeliveryDestinationType: S3
      OutputFormat: json
      DestinationResourceArn: !GetAtt LogBucket.Arn

  Delivery:
    Type: AWS::Logs::Delivery
    Properties:
      DeliverySourceName: !Ref DeliverySource
      DeliveryDestinationArn: !GetAtt DeliveryDestination.Arn
      S3SuffixPath: "alb/{yyyy}/{MM}/{dd}"

Outputs:
  BucketName:
    Value: !Ref LogBucket
07-cf-logs.yaml
07-cf-logs.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 7/7 cf-logs (us-east-1). CloudFront standard logging v2 to S3, added to an existing distribution
  from a separate stack. The distribution ARN comes from the cloudfront stack through Fn::GetStackOutput.

Resources:
  LogBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete
    Properties:
      BucketName: !Sub gwcf-cf-logs-${AWS::AccountId}-${AWS::Region}
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerEnforced
      LifecycleConfiguration:
        Rules:
          - Id: expire-logs
            Status: Enabled
            ExpirationInDays: 7

  LogBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref LogBucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowLogDeliveryPutObject
            Effect: Allow
            Principal: { Service: delivery.logs.amazonaws.com }
            Action: s3:PutObject
            Resource: !Sub ${LogBucket.Arn}/*
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              ArnLike:
                aws:SourceArn: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:delivery-source:*
          - Sid: AllowLogDeliveryGetBucketAcl
            Effect: Allow
            Principal: { Service: delivery.logs.amazonaws.com }
            Action: s3:GetBucketAcl
            Resource: !GetAtt LogBucket.Arn
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              ArnLike:
                aws:SourceArn: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:delivery-source:*

  DeliverySource:
    Type: AWS::Logs::DeliverySource
    Properties:
      Name: gwcf-cf-access-logs
      ResourceArn:
        Fn::GetStackOutput:
          StackName: gwcf-cloudfront
          OutputName: DistributionArn
          Region: us-east-1
      LogType: ACCESS_LOGS

  DeliveryDestination:
    Type: AWS::Logs::DeliveryDestination
    DependsOn: LogBucketPolicy
    Properties:
      Name: gwcf-cf-logs-s3-json
      DeliveryDestinationType: S3
      OutputFormat: json
      DestinationResourceArn: !GetAtt LogBucket.Arn

  Delivery:
    Type: AWS::Logs::Delivery
    Properties:
      DeliverySourceName: !Ref DeliverySource
      DeliveryDestinationArn: !GetAtt DeliveryDestination.Arn
      S3SuffixPath: "cf/{yyyy}/{MM}/{dd}"

Outputs:
  BucketName:
    Value: !Ref LogBucket

Verification

Reaching the service from CloudFront

At the stage where traffic from the prefix list was allowed, sending a request to the CloudFront domain returned a response from Nginx in the private subnet.

HTTP/2 200 
content-type: text/plain
content-length: 279
date: Fri, 13 Nov 2026 16:30:39 GMT
server: nginx/1.30.6
x-cache: Miss from cloudfront

service=gwcf-nginx-1
host=gw-<32桁のID>.ecs.ap-northeast-1.on.aws
http_host=gw-<32桁のID>.ecs.ap-northeast-1.on.aws
x_forwarded_host=
x_forwarded_proto=https
x_forwarded_for=<client-ip>, <cloudfront-ip>
BASE_URL=https://example.invalid

Narrowing the inbound source

After confirming connectivity with the prefix list specified, I updated gwcf-gateway by passing the managed security group ID as VpcOriginSecurityGroupId. Connectivity from CloudFront still worked after the update.

aws cloudformation deploy --region ap-northeast-1 --stack-name gwcf-gateway --template-file 02-gateway.yaml --capabilities CAPABILITY_NAMED_IAM --no-fail-on-empty-changeset --parameter-overrides CloudFrontPrefixListId=<PREFIX_LIST_ID> VpcOriginSecurityGroupId=<VPC_ORIGIN_SG_ID>

Host header and BASE_URL

Because the ECS Gateway ALB routes to services by hostname, the origin request policy is Managed-AllViewerExceptHostHeader, which does not forward Host. As a result, the host that reaches the application is not the CloudFront domain but the on.aws hostname of the serviceUrl.

Applications that use the host for things like page transitions need to be given the public URL through an environment variable such as BASE_URL.

When I updated gwcf-nginx with UseCloudFrontDomain=true and passed the DistributionDomainName with https:// prepended to the ECS environment variable BASE_URL, the response was as follows.

service=gwcf-nginx-1
host=gw-<32桁のID>.ecs.ap-northeast-1.on.aws
http_host=gw-<32桁のID>.ecs.ap-northeast-1.on.aws
x_forwarded_host=
x_forwarded_proto=https
x_forwarded_for=<client-ip>, <cloudfront-ip>
BASE_URL=https://<CLOUDFRONT_DOMAIN>

Conclusion

I was able to create ECS Gateway, an ECS service, a VPC origin, CloudFront, and more with CloudFormation templates.

Because the ECS Gateway ALB routes by hostname, you may need to set BASE_URL (such as NUXT_APP_BASE_URL) in the application's environment variables. Even so, a shared ALB can be achieved simply.

If you want to consolidate many services onto as few ALBs as possible, such as in a multi-tenant environment, or restrict access to only go through CloudFront without exposing the ALB, give this configuration a try.

For values that cannot be referenced between stacks, such as the ALB ARN that does not exist when ECS Gateway is created, I retrieved them with the CLI and passed them in this time. I would like to try approaches using Parameter Store or custom resources in the future.

Share this article

AWSのお困り事はクラスメソッドへ