I tried building an ECS Express Mode environment in a VPC without public subnets
This page has been translated by machine translation. View original
With the introduction of the Regional NAT Gateway released in November 2025, it has become possible to use NAT Gateway for internet communication from within a VPC, even in VPCs that omit public subnets.
This time, we had the opportunity to build an ECS Express Mode environment (released around the same time) on a VPC that has no public subnets and only has private subnets with IPv6 enabled. By combining this with CloudFront's VPC origin support, we verified the implementation of a web delivery infrastructure that does not depend on public subnets.
This configuration uses an Internal ALB (managed by Express Mode) and CloudFront VPC Origin to achieve a secure container environment without public IP addresses. This article introduces the CloudFormation templates used for the setup and the results of operation verification in the AWS environment.
Preparing the Test Image
As a verification application, we prepared a simple server using Node.js 22. It implements source IP address checking and health check functionality. We executed the following script on CloudShell and completed the registration to ECR.
Test Image Creation Script
#!/bin/bash
# CloudShell用ECRテストイメージ作成スクリプト
# ECS Express Mode Test Application
set -e
# 設定値
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=${AWS_REGION:-$(aws configure get region 2>/dev/null || echo "ap-northeast-1")}
REPO_NAME="ecs-express-test"
IMAGE_TAG="latest"
echo "=== ECS Express Mode Test Image Setup ==="
echo "Account ID: $ACCOUNT_ID"
echo "Region: $REGION"
echo "Repository: $REPO_NAME"
echo ""
# Docker確認
echo "Docker environment check..."
docker --version
echo ""
# 1. ECRリポジトリ作成
echo "Creating ECR repository..."
aws ecr create-repository \
--repository-name $REPO_NAME \
--region $REGION \
--image-scanning-configuration scanOnPush=true \
--no-cli-pager 2>/dev/null || echo "Repository already exists"
# 2. テストアプリケーション作成
echo "Creating test application..."
mkdir -p test-app
cd test-app
# package.json作成
cat > package.json << 'EOF'
{
"name": "ecs-express-test",
"version": "1.0.0",
"description": "ECS Test Application (node22)",
"main": "app.js",
"scripts": {
"start": "node app.js"
}
}
EOF
# app.js作成
cat > app.js << 'EOF'
const http = require('http');
const https = require('https');
const url = require('url');
const port = 3000;
// IPアドレス取得関数(修正版)
function getExternalIP(callback) {
const options = {
hostname: 'api64.ipify.org',
path: '?format=json',
method: 'GET',
timeout: 5000
};
// コールバックの二重呼び出し防止用フラグ
let isCalled = false;
const done = (err, res) => {
if (isCalled) return;
isCalled = true;
callback(err, res);
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const result = JSON.parse(data);
done(null, result);
} catch (error) {
// ここで生データもログに出しておくとデバッグしやすい
console.error("Parse Error Data:", data);
done(error, null);
}
});
});
req.on('error', (error) => {
done(error, null);
});
req.on('timeout', () => {
req.destroy();
done(new Error('Request timeout'), null);
});
req.end();
}
const server = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === '/') {
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify({
message: 'ECS Test Application (node22)',
timestamp: new Date().toISOString(),
hostname: require('os').hostname(),
environment: process.env.NODE_ENV || 'development',
version: '1.0.0'
}, null, 2));
} else if (parsedUrl.pathname === '/api/health/') {
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify({
status: 'healthy',
uptime: process.uptime(),
memory: process.memoryUsage(),
timestamp: new Date().toISOString()
}, null, 2));
} else if (parsedUrl.pathname === '/api/ipify/') {
res.setHeader('Content-Type', 'application/json');
getExternalIP((error, result) => {
if (error) {
res.writeHead(500);
res.end(JSON.stringify({
error: 'Failed to get external IP',
message: error.message,
timestamp: new Date().toISOString()
}, null, 2));
} else {
res.writeHead(200);
res.end(JSON.stringify({
...result,
timestamp: new Date().toISOString(),
source: 'api64.ipify.org',
note: 'ECS Node External IP Address'
}, null, 2));
}
});
} else if (parsedUrl.pathname === '/robots.txt') {
res.setHeader('Content-Type', 'text/plain');
res.writeHead(200);
res.end('User-agent: *\nDisallow: /');
} else {
res.setHeader('Content-Type', 'application/json');
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not Found' }));
}
});
server.listen(port, '0.0.0.0', () => {
console.log(`ECS Test Application (node22) listening on port ${port}`);
console.log(`Health check: http://localhost:${port}/api/health/`);
console.log(`Robots.txt: http://localhost:${port}/robots.txt`);
console.log(`IP check: http://localhost:${port}/api/ipify/`);
});
EOF
# Dockerfile作成
cat > Dockerfile << 'EOF'
FROM node:22-alpine
# メタデータ
LABEL maintainer="ECS Express Mode Test"
LABEL description="ECS Test Application (node22) with ECS Exec support"
# ECS Exec用の必要なツールをインストール
RUN apk add --no-cache \
curl \
ca-certificates \
bash \
procps
# アプリケーションディレクトリ作成
WORKDIR /app
# package.jsonをコピーして依存関係をインストール(キャッシュ効率化)
COPY package.json .
# 現在は依存関係なしのため npm install 不要
# RUN npm ci --only=production
# アプリケーションコードをコピー
COPY app.js .
# 非rootユーザーで実行
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
USER nodejs
# ポート3000を公開
EXPOSE 3000
# ヘルスチェック追加
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/api/health/ || exit 1
# アプリケーション実行
CMD ["npm", "start"]
EOF
# 3. ECR認証
echo "ECR authentication..."
aws ecr get-login-password --region $REGION 2>/dev/null | \
docker login --username AWS --password-stdin \
$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com >/dev/null 2>&1
if [ $? -eq 0 ]; then
echo "ECR authentication successful"
else
echo "ECR authentication failed"
exit 1
fi
# 4. Dockerイメージビルド
echo "Building Docker image..."
docker build -t $REPO_NAME:$IMAGE_TAG .
# 5. イメージタグ付け
echo "Tagging image..."
docker tag $REPO_NAME:$IMAGE_TAG \
$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$IMAGE_TAG
# 6. ECRにプッシュ
echo "Pushing to ECR..."
docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$IMAGE_TAG
# 7. イメージ情報確認
echo "Checking image information..."
aws ecr describe-images \
--repository-name $REPO_NAME \
--region $REGION \
--query 'imageDetails[0].[imageTags[0],imageSizeInBytes,imagePushedAt]' \
--output table
AWS Configuration
VPC
First, we created the foundational VPC.

-
Public subnetless: Configured with only private subnets that do not accept direct ingress traffic from the internet.
-
Regional NAT Gateway: Normally a NAT Gateway is placed per AZ for redundancy, but from the perspective of cost optimization and IPv6 utilization, we placed it in regional mode in only 1 AZ (AZ-a). By specifying
AvailabilityMode: regionaland explicitly stating the AZ to use, we achieved single-AZ operation. -
IPv6 support: We placed an Egress-Only Internet Gateway to enable IPv6 communication.
VPC Creation Template
AWSTemplateFormatVersion: '2010-09-09'
Description: 'ECS Express Mode Test VPC - Complete private VPC with Regional NAT Gateway'
Parameters:
ProjectName:
Type: String
Default: 'ecs-express-vpc'
Description: 'ECS Express Mode Test VPC - Project name prefix for AWS resources'
Resources:
# VPC
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: '192.168.0.0/18'
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Key: Name
Value: !Ref ProjectName
# IPv6 CIDR Block
IPv6CidrBlock:
Type: AWS::EC2::VPCCidrBlock
Properties:
VpcId: !Ref VPC
AmazonProvidedIpv6CidrBlock: true
# Private subnets
PrivateSubnet1:
Type: AWS::EC2::Subnet
DependsOn: IPv6CidrBlock
Properties:
VpcId: !Ref VPC
CidrBlock: '192.168.0.0/20'
AvailabilityZone: !Select [0, !GetAZs '']
Ipv6CidrBlock: !Select [0, !Cidr [!Select [0, !GetAtt VPC.Ipv6CidrBlocks], 3, 64]]
AssignIpv6AddressOnCreation: true
Tags:
- Key: Name
Value: !Sub '${ProjectName}-private-subnet-1a'
PrivateSubnet2:
Type: AWS::EC2::Subnet
DependsOn: IPv6CidrBlock
Properties:
VpcId: !Ref VPC
CidrBlock: '192.168.16.0/20'
AvailabilityZone: !Select [1, !GetAZs '']
Ipv6CidrBlock: !Select [1, !Cidr [!Select [0, !GetAtt VPC.Ipv6CidrBlocks], 3, 64]]
AssignIpv6AddressOnCreation: true
Tags:
- Key: Name
Value: !Sub '${ProjectName}-private-subnet-1c'
PrivateSubnet3:
Type: AWS::EC2::Subnet
DependsOn: IPv6CidrBlock
Properties:
VpcId: !Ref VPC
CidrBlock: '192.168.32.0/20'
AvailabilityZone: !Select [2, !GetAZs '']
Ipv6CidrBlock: !Select [2, !Cidr [!Select [0, !GetAtt VPC.Ipv6CidrBlocks], 3, 64]]
AssignIpv6AddressOnCreation: true
Tags:
- Key: Name
Value: !Sub '${ProjectName}-private-subnet-1d'
# Internet Gateway (for Regional NAT Gateway)
InternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: !Sub '${ProjectName}-igw'
# Internet Gateway Attachment
InternetGatewayAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref VPC
InternetGatewayId: !Ref InternetGateway
# Egress-Only Internet Gateway
EgressOnlyIGW:
Type: AWS::EC2::EgressOnlyInternetGateway
Properties:
VpcId: !Ref VPC
# Elastic IP for Regional NAT Gateway
NATGatewayEIP:
Type: AWS::EC2::EIP
Properties:
Domain: vpc
Tags:
- Key: Name
Value: !Sub '${ProjectName}-nat-eip'
# Regional NAT Gateway
RegionalNATGateway:
Type: AWS::EC2::NatGateway
Properties:
AvailabilityMode: regional
ConnectivityType: public
VpcId: !Ref VPC
AvailabilityZoneAddresses:
- AvailabilityZone: !Select [0, !GetAZs '']
AllocationIds:
- !GetAtt NATGatewayEIP.AllocationId
Tags:
- Key: Name
Value: !Sub '${ProjectName}-regional-nat-gw'
# Private route table
PrivateRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub '${ProjectName}-private-rt'
# IPv4 NAT Gateway route
PrivateRouteIPv4:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRouteTable
DestinationCidrBlock: '0.0.0.0/0'
NatGatewayId: !Ref RegionalNATGateway
# IPv6 Egress-Only route
PrivateRouteIPv6:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRouteTable
DestinationIpv6CidrBlock: '::/0'
EgressOnlyInternetGatewayId: !Ref EgressOnlyIGW
# Subnet associations
PrivateSubnet1Association:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PrivateSubnet1
RouteTableId: !Ref PrivateRouteTable
PrivateSubnet2Association:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PrivateSubnet2
RouteTableId: !Ref PrivateRouteTable
PrivateSubnet3Association:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PrivateSubnet3
RouteTableId: !Ref PrivateRouteTable
# VPC Endpoint - S3
S3Endpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref VPC
ServiceName: !Sub 'com.amazonaws.${AWS::Region}.s3'
VpcEndpointType: Gateway
RouteTableIds:
- !Ref PrivateRouteTable
# VPC Endpoint - DynamoDB
DynamoDBEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref VPC
ServiceName: !Sub 'com.amazonaws.${AWS::Region}.dynamodb'
VpcEndpointType: Gateway
RouteTableIds:
- !Ref PrivateRouteTable
Outputs:
VPCId:
Description: 'VPC ID'
Value: !Ref VPC
Export:
Name: !Sub '${ProjectName}-vpc-id'
PrivateSubnet1Id:
Description: 'Private Subnet 1 ID'
Value: !Ref PrivateSubnet1
Export:
Name: !Sub '${ProjectName}-private-subnet-1-id'
PrivateSubnet2Id:
Description: 'Private Subnet 2 ID'
Value: !Ref PrivateSubnet2
Export:
Name: !Sub '${ProjectName}-private-subnet-2-id'
PrivateSubnet3Id:
Description: 'Private Subnet 3 ID'
Value: !Ref PrivateSubnet3
Export:
Name: !Sub '${ProjectName}-private-subnet-3-id'
RegionalNATGatewayId:
Description: 'Regional NAT Gateway ID'
Value: !Ref RegionalNATGateway
Export:
Name: !Sub '${ProjectName}-regional-nat-gw-id'
ECS・CloudFront

-
Express Mode: AWS::ECS::ExpressGatewayServiceresource was used to create the Express service. -
Internal ALB: By specifying private subnets, Express Mode automatically provisioned an Internal ALB.
-
CloudFront VPC Origin: To enable connectivity from CloudFront to the Internal ALB, a VPC Origin (AWS::CloudFront::VpcOrigin) was created. The Express Mode load balancer ARN is specified as the target.
Initial ECS and CloudFront creation template
AWSTemplateFormatVersion: '2010-09-09'
Description: 'ECS Express Mode Service - CloudFormation equivalent of CLI creation'
Parameters:
ServiceName:
Type: String
Default: 'ecs-express-initial'
Description: 'Service name for resources'
ContainerImage:
Type: String
Default: '****.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-express-test:latest'
Description: 'Container image URI'
VPCId:
Type: AWS::EC2::VPC::Id
Default: 'vpc-****'
Description: 'VPC ID for the ECS service'
PrivateSubnets:
Type: List<AWS::EC2::Subnet::Id>
Default: 'subnet-****,subnet-****,subnet-****'
Description: 'Private subnet IDs (comma-separated)'
CreateCloudFront:
Type: String
Default: 'false'
AllowedValues: ['true', 'false']
Description: 'Create CloudFront Distribution (true/false)'
Conditions:
ShouldCreateCloudFront: !Equals [!Ref CreateCloudFront, 'true']
Resources:
# CloudWatch Log Group
ECSLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/aws/ecs/default/${ServiceName}-service'
RetentionInDays: 14
# IAM Role for ECS Task Execution
ECSTaskExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${ServiceName}-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
# IAM Role for Task (SSM permissions for ECS Exec)
ECSTaskRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${ServiceName}-task-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ecs-tasks.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: ECSExecPolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- ssmmessages:CreateControlChannel
- ssmmessages:CreateDataChannel
- ssmmessages:OpenControlChannel
- ssmmessages:OpenDataChannel
Resource: "*"
# IAM Role for ECS Infrastructure with additional permissions
ECSInfrastructureRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${ServiceName}-infrastructure-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ecs.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices
Policies:
- PolicyName: AdditionalAutoScalingPermissions
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- application-autoscaling:DeregisterScalableTarget
- application-autoscaling:DeleteScalingPolicy
- application-autoscaling:DescribeScalableTargets
- application-autoscaling:DescribeScalingPolicies
Resource: '*'
# ECS Express Mode Service
ExpressModeService:
Type: AWS::ECS::ExpressGatewayService
DependsOn: ECSLogGroup
Properties:
ServiceName: !Sub '${ServiceName}-service'
Cluster: 'default'
ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
InfrastructureRoleArn: !GetAtt ECSInfrastructureRole.Arn
TaskRoleArn: !GetAtt ECSTaskRole.Arn
# Primary Container Configuration
PrimaryContainer:
Image: !Ref ContainerImage
ContainerPort: 3000
AwsLogsConfiguration:
LogGroup: !Sub '/aws/ecs/default/${ServiceName}-service'
LogStreamPrefix: 'ecs'
# Network Configuration
NetworkConfiguration:
Subnets: !Ref PrivateSubnets
# Resource Allocation
Cpu: '1024'
Memory: '2048'
# Health Check
HealthCheckPath: '/api/health/'
# Auto Scaling Configuration
ScalingTarget:
MinTaskCount: 1
MaxTaskCount: 3
AutoScalingMetric: 'AVERAGE_CPU'
AutoScalingTargetValue: 60
# Tags
Tags:
- Key: Name
Value: !Sub '${ServiceName}-express-service'
- Key: Environment
Value: 'development'
- Key: Project
Value: !Ref ServiceName
# CloudFront VPC Origin for Express Mode ALB
ExpressModeVpcOrigin:
Type: AWS::CloudFront::VpcOrigin
Properties:
VpcOriginEndpointConfig:
Name: !Sub '${ServiceName}-initial-origin'
Arn: !GetAtt ExpressModeService.ECSManagedResourceArns.IngressPath.LoadBalancerArn
HTTPPort: 80
HTTPSPort: 443
OriginProtocolPolicy: 'https-only'
# CloudFront Distribution
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Condition: ShouldCreateCloudFront
Properties:
DistributionConfig:
Comment: !Sub 'CloudFront for ${ServiceName} - ${ExpressModeService.Endpoint}'
Enabled: true
HttpVersion: http2and3
IPV6Enabled: true
# Custom Domain Configuration
Aliases:
- !Sub '${ServiceName}.developers.io'
ViewerCertificate:
AcmCertificateArn: 'arn:aws:acm:us-east-1:****:certificate/9bfb09c0-88aa-48e9-8685-5a176271dc91'
SslSupportMethod: sni-only
MinimumProtocolVersion: TLSv1.2_2021
# VPC Origin Configuration
Origins:
- Id: VPCOrigin
DomainName: !GetAtt ExpressModeService.Endpoint
VpcOriginConfig:
VpcOriginId: !Ref ExpressModeVpcOrigin
# Default Cache Behavior
DefaultCacheBehavior:
TargetOriginId: VPCOrigin
ViewerProtocolPolicy: https-only
Compress: true
# Cache Settings
MinTTL: 0
DefaultTTL: 60
MaxTTL: 120
ForwardedValues:
QueryString: true
Cookies:
Forward: none
Tags:
- Key: Name
Value: !Sub '${AWS::StackName}-distribution'
Outputs:
ServiceEndpoint:
Description: 'ECS Express Mode Service Endpoint'
Value: !GetAtt ExpressModeService.Endpoint
Export:
Name: !Sub '${AWS::StackName}-ServiceEndpoint'
TaskExecutionRoleArn:
Description: 'ECS Task Execution Role ARN'
Value: !GetAtt ECSTaskExecutionRole.Arn
Export:
Name: !Sub '${AWS::StackName}-TaskExecutionRoleArn'
InfrastructureRoleArn:
Description: 'ECS Infrastructure Role ARN'
Value: !GetAtt ECSInfrastructureRole.Arn
Export:
Name: !Sub '${AWS::StackName}-InfrastructureRoleArn'
TaskRoleArn:
Description: 'ECS Task Role ARN'
Value: !GetAtt ECSTaskRole.Arn
Export:
Name: !Sub '${AWS::StackName}-TaskRoleArn'
PrivateSubnets:
Description: 'Private Subnet IDs'
Value: !Join [',', !Ref PrivateSubnets]
Export:
Name: !Sub '${AWS::StackName}-PrivateSubnets'
VPCOriginArn:
Description: 'CloudFront VPC Origin ARN'
Value: !Ref ExpressModeVpcOrigin
Export:
Name: !Sub '${AWS::StackName}-VPCOriginArn'
VPCId:
Description: 'VPC ID'
Value: !Ref VPCId
Export:
Name: !Sub '${AWS::StackName}-VPCId'
CloudFrontDistributionId:
Condition: ShouldCreateCloudFront
Description: 'CloudFront Distribution ID'
Value: !Ref CloudFrontDistribution
CloudFrontDomainName:
Condition: ShouldCreateCloudFront
Description: 'CloudFront Distribution Domain Name'
Value: !GetAtt CloudFrontDistribution.DomainName
CloudFrontURL:
Condition: ShouldCreateCloudFront
Description: 'CloudFront Distribution URL'
Value: !Sub 'https://${CloudFrontDistribution.DomainName}'
CustomDomainURL:
Description: 'Custom Domain URL'
Value: !Sub 'https://${ServiceName}.developers.io'
Adding Express Services

-
ELB sharing: When adding an Express service, by specifying the same subnets as the first service, the existing ALB is configured to be shared.
-
Routing: The CloudFront origin settings are reused, while the application URL issued per Express service (de-***.ecs.<region>.on.aws) is set as the target.
Adding Express Service Template
AWSTemplateFormatVersion: '2010-09-09'
Description: 'ECS Express Mode Secondary Service - References initial template resources'
Parameters:
ServiceName:
Type: String
Default: 'ecs-express-secondary'
Description: 'Service name for resources'
InitialStackName:
Type: String
Default: 'ecs-express-initial'
Description: 'Initial stack name to reference VPC and IAM roles'
ContainerImage:
Type: String
Default: '****.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-express-test:latest'
Description: 'Container image URI'
ContainerPort:
Type: Number
Default: 3000
Description: 'Container port number'
CreateCloudFront:
Type: String
Default: 'false'
AllowedValues: ['true', 'false']
Description: 'Create CloudFront Distribution (true/false)'
Conditions:
ShouldCreateCloudFront: !Equals [!Ref CreateCloudFront, 'true']
Resources:
# CloudWatch Log Group
ECSLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/aws/ecs/default/${ServiceName}-service'
RetentionInDays: 14
# ECS Express Mode Service
ExpressModeService:
Type: AWS::ECS::ExpressGatewayService
DependsOn: ECSLogGroup
Properties:
ServiceName: !Sub '${ServiceName}-service'
Cluster: 'default'
ExecutionRoleArn:
Fn::ImportValue: !Sub '${InitialStackName}-TaskExecutionRoleArn'
InfrastructureRoleArn:
Fn::ImportValue: !Sub '${InitialStackName}-InfrastructureRoleArn'
TaskRoleArn:
Fn::ImportValue: !Sub '${InitialStackName}-TaskRoleArn'
# Primary Container Configuration
PrimaryContainer:
Image: !Ref ContainerImage
ContainerPort: !Ref ContainerPort
AwsLogsConfiguration:
LogGroup: !Sub '/aws/ecs/default/${ServiceName}-service'
LogStreamPrefix: 'ecs'
# Network Configuration
NetworkConfiguration:
Subnets:
Fn::Split:
- ','
- Fn::ImportValue: !Sub '${InitialStackName}-PrivateSubnets'
# Resource Allocation
Cpu: '1024'
Memory: '2048'
# Health Check
HealthCheckPath: '/'
# Auto Scaling Configuration
ScalingTarget:
MinTaskCount: 1
MaxTaskCount: 20
AutoScalingMetric: 'AVERAGE_CPU'
AutoScalingTargetValue: 60
# Tags
Tags:
- Key: Name
Value: !Sub '${ServiceName}-express-service'
- Key: Environment
Value: 'development'
- Key: Project
Value: !Ref ServiceName
# CloudFront Distribution
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Condition: ShouldCreateCloudFront
Properties:
DistributionConfig:
Comment: !Sub 'CloudFront for ${ServiceName} - ${ExpressModeService.Endpoint}'
Enabled: true
HttpVersion: http2and3
IPV6Enabled: true
# VPC Origin Configuration
Origins:
- Id: VPCOrigin
DomainName: !GetAtt ExpressModeService.Endpoint
VpcOriginConfig:
VpcOriginId:
Fn::ImportValue: !Sub '${InitialStackName}-VPCOriginArn'
# Default Cache Behavior
DefaultCacheBehavior:
TargetOriginId: VPCOrigin
ViewerProtocolPolicy: https-only
Compress: true
# Cache Settings
MinTTL: 0
DefaultTTL: 60
MaxTTL: 120
ForwardedValues:
QueryString: true
Cookies:
Forward: none
Tags:
- Key: Name
Value: !Sub '${AWS::StackName}-distribution'
Outputs:
ServiceEndpoint:
Description: 'ECS Express Mode Service Endpoint'
Value: !GetAtt ExpressModeService.Endpoint
CloudFrontDistributionId:
Condition: ShouldCreateCloudFront
Description: 'CloudFront Distribution ID'
Value: !Ref CloudFrontDistribution
CloudFrontDomainName:
Condition: ShouldCreateCloudFront
Description: 'CloudFront Distribution Domain Name'
Value: !GetAtt CloudFrontDistribution.DomainName
CloudFrontURL:
Condition: ShouldCreateCloudFront
Description: 'CloudFront Distribution URL'
Value: !Sub 'https://${CloudFrontDistribution.DomainName}'
Connectivity Verification
Connectivity was verified from both external (via CloudFront) and internal (CloudShell within the VPC) against the constructed environment.
1. Access via CloudFront
curl was executed against the CloudFront domain.
! curl https://***.cloudfront.net -v
<omitted>
> GET / HTTP/2
> Host: ***.cloudfront.net
> User-Agent: curl/8.7.1
> Accept: */*
<omitted>
< via: 1.1 xxxx.cloudfront.net (CloudFront)
<omitted>
<
{
"message": "ECS Test Application (node22)",
"timestamp": "2025-12-14T06:22:24.744Z",
"hostname": "ip-192-168-**-**.ap-northeast-1.compute.internal",
"environment": "development",
"version": "1.0.0"
* Connection #0 to host ***.cloudfront.net left intact
}
The response header contained via: ... (CloudFront), confirming that the response was successfully returned from the application on ECS via CloudFront.
2. Access from inside the VPC (CloudShell)
A direct request was made from CloudShell placed within the VPC to the Express service application URL.
$ curl https://**-**.ecs.ap-northeast-1.on.aws -v
<omitted>
> GET / HTTP/2
> Host: **-**.ecs.ap-northeast-1.on.aws
> User-Agent: curl/8.11.1
> Accept: */*
>
* Request completely sent off
< HTTP/2 200
< date: Sun, 14 Dec 2025 06:31:42 GMT
< content-type: application/json
<
{
"message": "ECS Test Application (node22)",
"timestamp": "2025-12-14T06:31:42.650Z",
"hostname": "ip-192-168-**-**.ap-northeast-1.compute.internal",
"environment": "development",
"version": "1.0.0"
* Connection #0 to host **-**.ecs.ap-northeast-1.on.aws left intact
}
Confirmed that a successful response was obtained via the Internal ALB.
Summary
Through this verification, we confirmed that combining ECS Express Mode with a Regional NAT Gateway makes it possible to achieve a rational and cost-efficient configuration in terms of both operational burden and cost.
From an operational perspective, by leveraging the application URLs issued by Express Mode, it becomes possible to simply share a single Internal ALB across multiple services without the complex configurations associated with traditional path-based routing.
From a cost perspective, in addition to sharing the ELB, using Internal mode—which does not incur IPv4 public IP address charges—minimizes costs related to ELB. Furthermore, by consolidating NAT Gateway into a single AZ in regional mode, we determined that maintenance costs can be significantly reduced compared to standard multi-AZ deployments, despite the trade-off with availability design.
Additionally, an architecture that limits external exposure points solely to CloudFront by using CloudFront VPC Origin is also excellent in that it allows centralized management of security measures using AWS WAF. If you are looking to reduce IPv4 address-related costs or VPC endpoint maintenance expenses, please try out the configuration introduced here.
