I tried building an ECS Express Mode environment in a VPC without public subnets

I tried building an ECS Express Mode environment in a VPC without public subnets

In November 2025, I built a VPC environment without public subnets using CloudFormation, combining the newly released Regional NAT Gateway and ECS Express Mode. I will introduce the procedures for establishing a secure delivery path using Internal ALB and CloudFront VPC Origin, along with the connectivity verification results on actual hardware.
2025.12.14

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 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: regional and 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

ECS Express Mode and CloudFront

  • Express Mode: AWS::ECS::ExpressGatewayService resource 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

ECS Express Mode service addition image

  • 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.

Share this article

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