Skip to content

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

AZ-A

public · 10.90.1.0/24

Gateway ALB

NAT gateway

private · 10.90.11.0/24

ECS tasks / EC2

AZ-B

public · 10.90.2.0/24

Gateway ALB

private · 10.90.12.0/24

ECS tasks / EC2

The ALB spans both public subnets. Private subnets route outbound through the single NAT gateway in AZ-A → internet gateway.

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.

Terminal window
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_B
echo "$AZ_A $AZ_B"
Terminal window
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=true
aws 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"
Terminal window
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-launch
aws 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"
Terminal window
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)”
Terminal window
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.

Infra roles