Create VPC
Internet-facing load balancers sit in public subnets. ECS tasks and the EC2 container instance sit in private subnets. Outbound pulls (images, ECS agent, logs) use a NAT gateway. Both the EC2 and Fargate paths share this VPC.
VPC 10.90.0.0/16
Internet gateway
public · 10.90.1.0/24
Gateway ALB
NAT gateway
private · 10.90.11.0/24
ECS tasks / EC2
public · 10.90.2.0/24
Gateway ALB
private · 10.90.12.0/24
ECS tasks / EC2
Reuse an existing two-AZ VPC with the same layout if you already have one — export the IDs into the same variable names and skip to Infra roles.
Pick two Availability Zones
Section titled “Pick two Availability Zones”AZ_A=$(aws ec2 describe-availability-zones \ --query 'AvailabilityZones[?State==`available`].ZoneName | [0]' \ --output text)AZ_B=$(aws ec2 describe-availability-zones \ --query 'AvailabilityZones[?State==`available`].ZoneName | [1]' \ --output text)export AZ_A AZ_Becho "$AZ_A $AZ_B"VPC and internet gateway
Section titled “VPC and internet gateway”VPC_ID=$(aws ec2 create-vpc \ --cidr-block 10.90.0.0/16 \ --tag-specifications "ResourceType=vpc,Tags=[{Key=Name,Value=${NAME}}]" \ --query Vpc.VpcId --output text)export VPC_ID
aws ec2 modify-vpc-attribute --vpc-id "$VPC_ID" --enable-dns-support Value=trueaws ec2 modify-vpc-attribute --vpc-id "$VPC_ID" --enable-dns-hostnames Value=true
IGW_ID=$(aws ec2 create-internet-gateway \ --tag-specifications "ResourceType=internet-gateway,Tags=[{Key=Name,Value=${NAME}}]" \ --query InternetGateway.InternetGatewayId --output text)export IGW_ID
aws ec2 attach-internet-gateway --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID"Public subnets (ALB + NAT)
Section titled “Public subnets (ALB + NAT)”PUBLIC_SUBNET_A=$(aws ec2 create-subnet \ --vpc-id "$VPC_ID" \ --cidr-block 10.90.1.0/24 \ --availability-zone "$AZ_A" \ --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=${NAME}-public-a}]" \ --query Subnet.SubnetId --output text)
PUBLIC_SUBNET_B=$(aws ec2 create-subnet \ --vpc-id "$VPC_ID" \ --cidr-block 10.90.2.0/24 \ --availability-zone "$AZ_B" \ --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=${NAME}-public-b}]" \ --query Subnet.SubnetId --output text)export PUBLIC_SUBNET_A PUBLIC_SUBNET_B
aws ec2 modify-subnet-attribute --subnet-id "$PUBLIC_SUBNET_A" --map-public-ip-on-launchaws ec2 modify-subnet-attribute --subnet-id "$PUBLIC_SUBNET_B" --map-public-ip-on-launch
PUBLIC_RTB_ID=$(aws ec2 create-route-table \ --vpc-id "$VPC_ID" \ --tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=${NAME}-public}]" \ --query RouteTable.RouteTableId --output text)export PUBLIC_RTB_ID
aws ec2 create-route --route-table-id "$PUBLIC_RTB_ID" \ --destination-cidr-block 0.0.0.0/0 --gateway-id "$IGW_ID"
aws ec2 associate-route-table --route-table-id "$PUBLIC_RTB_ID" --subnet-id "$PUBLIC_SUBNET_A"aws ec2 associate-route-table --route-table-id "$PUBLIC_RTB_ID" --subnet-id "$PUBLIC_SUBNET_B"NAT gateway
Section titled “NAT gateway”EIP_ALLOC_ID=$(aws ec2 allocate-address --domain vpc \ --tag-specifications "ResourceType=elastic-ip,Tags=[{Key=Name,Value=${NAME}-nat}]" \ --query AllocationId --output text)export EIP_ALLOC_ID
NAT_GW_ID=$(aws ec2 create-nat-gateway \ --subnet-id "$PUBLIC_SUBNET_A" \ --allocation-id "$EIP_ALLOC_ID" \ --tag-specifications "ResourceType=natgateway,Tags=[{Key=Name,Value=${NAME}}]" \ --query NatGateway.NatGatewayId --output text)export NAT_GW_ID
echo "Waiting for NAT gateway (often 1–3 minutes)..."aws ec2 wait nat-gateway-available --nat-gateway-ids "$NAT_GW_ID"echo "NAT ready"Do not create the private default route until the NAT gateway is available. If wait times out, check aws ec2 describe-nat-gateways --nat-gateway-ids "$NAT_GW_ID".
Private subnets (ECS tasks / EC2 instance)
Section titled “Private subnets (ECS tasks / EC2 instance)”PRIVATE_SUBNET_A=$(aws ec2 create-subnet \ --vpc-id "$VPC_ID" \ --cidr-block 10.90.11.0/24 \ --availability-zone "$AZ_A" \ --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=${NAME}-private-a}]" \ --query Subnet.SubnetId --output text)
PRIVATE_SUBNET_B=$(aws ec2 create-subnet \ --vpc-id "$VPC_ID" \ --cidr-block 10.90.12.0/24 \ --availability-zone "$AZ_B" \ --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=${NAME}-private-b}]" \ --query Subnet.SubnetId --output text)export PRIVATE_SUBNET_A PRIVATE_SUBNET_B
PRIVATE_RTB_ID=$(aws ec2 create-route-table \ --vpc-id "$VPC_ID" \ --tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=${NAME}-private}]" \ --query RouteTable.RouteTableId --output text)export PRIVATE_RTB_ID
aws ec2 create-route --route-table-id "$PRIVATE_RTB_ID" \ --destination-cidr-block 0.0.0.0/0 --nat-gateway-id "$NAT_GW_ID"
aws ec2 associate-route-table --route-table-id "$PRIVATE_RTB_ID" --subnet-id "$PRIVATE_SUBNET_A"aws ec2 associate-route-table --route-table-id "$PRIVATE_RTB_ID" --subnet-id "$PRIVATE_SUBNET_B"
echo "VPC=$VPC_ID"echo "PUBLIC $PUBLIC_SUBNET_A ($AZ_A) $PUBLIC_SUBNET_B ($AZ_B)"echo "PRIVATE $PRIVATE_SUBNET_A ($AZ_A) $PRIVATE_SUBNET_B ($AZ_B)"Gateway ALBs use the public pair. Services and the EC2 instance use the private pair (assignPublicIp=DISABLED). AZ names match across public and private so gateway AZ coverage stays compatible with the services.