
I Built an ECS Gateway and CloudFront VPC Origin Configuration Using CloudFormation and the CLI
This page has been translated by machine translation. View original
Introduction
On October 20, 2026, Amazon ECS Gateway was announced.
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.
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.

| 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-SGthat 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.
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) |
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.
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
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
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.
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
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.
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.
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
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
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.
