Scenario
You have a web application that should only be accessible internally within a VPC. The setup is pretty standard: EC2 instances are in private subnets behind an Application Load Balancer which is in public subnets, with a friendly DNS name pointing to ALB for internal access. There's already a startup script at /tmp/userdata.sh on local on the machine in the provided terminal that spins up a simple HTTP server with a /health endpoint on port 80.
Task
- Create a VPC named
app-vpc using the CIDR block 10.0.0.0/16 with public and private subnets across two AZs. Enable internet connectivity for public subnets while keeping private subnets isolated.
- Create two security groups to enforce the following:
| Security Group |
Direction |
Requirement |
| alb-sg |
Inbound |
Allow HTTP access from internal networks only |
| ec2-sg |
Inbound |
Allow HTTP access only from the load balancer |
- Launch two EC2 instances (one per private subnet) using an Amazon Linux 2 AMI (
amzn2-ami-hvm-*-x86_64-gp2). Attach the appropriate security group and configure the instances to run the provided startup script located at /tmp/userdata.sh on boot.
- Create an ALB named
app-alb in the public subnets with appropriate security group. Create a target group app-tg with a /health check, register both instances, and add an HTTP listener on port 80.
- Create DNS entry
internal.example.com associated with app-vpc, and add a CNAME DNS record app.internal.example.com pointing to the ALB.
Note: You can use either the AWS Management Console or AWS CLI to complete this task.
Step 1: Create the VPC
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
--tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=app-vpc}]' \
--query 'Vpc.VpcId' --output text)
This gives us the VPC that everything else will go into. We save the ID to $VPC_ID since pretty much every command after this needs it.
Step 2: Create Public and Private Subnets
PUB_SUBNET_A=$(aws ec2 create-subnet --vpc-id $VPC_ID \
--cidr-block 10.0.1.0/24 --availability-zone us-east-1a \
--query 'Subnet.SubnetId' --output text)
PUB_SUBNET_B=$(aws ec2 create-subnet --vpc-id $VPC_ID \
--cidr-block 10.0.2.0/24 --availability-zone us-east-1b \
--query 'Subnet.SubnetId' --output text)
PRIV_SUBNET_A=$(aws ec2 create-subnet --vpc-id $VPC_ID \
--cidr-block 10.0.3.0/24 --availability-zone us-east-1a \
--query 'Subnet.SubnetId' --output text)
PRIV_SUBNET_B=$(aws ec2 create-subnet --vpc-id $VPC_ID \
--cidr-block 10.0.4.0/24 --availability-zone us-east-1b \
--query 'Subnet.SubnetId' --output text)
We need subnets in two AZs for high availability. The public ones are for the ALB, and the private ones are where the EC2 instances will sit, keeping them off the public internet.
Step 3: Attach Internet Gateway and Configure Public Routing
IGW_ID=$(aws ec2 create-internet-gateway \
--query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID
PUB_RT=$(aws ec2 create-route-table --vpc-id $VPC_ID \
--query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id $PUB_RT \
--destination-cidr-block 0.0.0.0/0 --gateway-id $IGW_ID
aws ec2 associate-route-table --route-table-id $PUB_RT --subnet-id $PUB_SUBNET_A
aws ec2 associate-route-table --route-table-id $PUB_RT --subnet-id $PUB_SUBNET_B
The Internet Gateway is what lets traffic actually reach the ALB from outside. Notice we only associate the public subnets with this route table. The private subnets keep their default local-only routing, so the EC2 instances have no direct path to or from the internet.
Step 4: Create Security Groups
ALB_SG=$(aws ec2 create-security-group \
--group-name alb-sg --description "ALB SG" --vpc-id $VPC_ID \
--query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress \
--group-id $ALB_SG --protocol tcp --port 80 --cidr 10.0.0.0/8
EC2_SG=$(aws ec2 create-security-group \
--group-name ec2-sg --description "EC2 SG" --vpc-id $VPC_ID \
--query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress \
--group-id $EC2_SG --protocol tcp --port 80 --source-group $ALB_SG
alb-sg locks down the ALB to only accept traffic from the 10.0.0.0/8 range. For ec2-sg, we use --source-group instead of a CIDR block. This way the EC2 instances will only accept traffic that actually originates from the ALB's security group, not just from any IP that happens to fall in the right range.
Step 5: Launch EC2 Instances in Private Subnets
AMI_ID=$(aws ec2 describe-images \
--filters "Name=name,Values=amzn2-ami-hvm-*-x86_64-gp2" "Name=state,Values=available" \
--query 'sort_by(Images, &CreationDate)[-1].ImageId' --output text)
INSTANCE_A=$(aws ec2 run-instances \
--image-id $AMI_ID --instance-type t3.micro \
--subnet-id $PRIV_SUBNET_A --security-group-ids $EC2_SG \
--user-data file:///tmp/userdata.sh \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=app-instance-a}]' \
--query 'Instances[0].InstanceId' --output text)
INSTANCE_B=$(aws ec2 run-instances \
--image-id $AMI_ID --instance-type t3.micro \
--subnet-id $PRIV_SUBNET_B --security-group-ids $EC2_SG \
--user-data file:///tmp/userdata.sh \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=app-instance-b}]' \
--query 'Instances[0].InstanceId' --output text)
The userdata.sh script runs at first boot and starts a simple HTTP server on port 80. It also sets up the /health endpoint that the ALB will use for health checks. When you pass file:///tmp/userdata.sh to --user-data, the CLI handles reading and base64-encoding the file for you.
Step 6: Create the Target Group and Register Instances
TG_ARN=$(aws elbv2 create-target-group \
--name app-tg --protocol HTTP --port 80 --vpc-id $VPC_ID \
--health-check-path /health \
--query 'TargetGroups[0].TargetGroupArn' --output text)
aws elbv2 register-targets --target-group-arn $TG_ARN \
--targets Id=$INSTANCE_A Id=$INSTANCE_B
The target group connects the ALB to the actual EC2 instances. It keeps checking /health on each instance, and the ALB will only send traffic to instances that are currently passing that check.
Step 7: Create the ALB and Listener
ALB_ARN=$(aws elbv2 create-load-balancer \
--name app-alb --type application --scheme internal \
--subnets $PUB_SUBNET_A $PUB_SUBNET_B \
--security-groups $ALB_SG \
--query 'LoadBalancers[0].LoadBalancerArn' --output text)
ALB_DNS=$(aws elbv2 describe-load-balancers \
--load-balancer-arns $ALB_ARN \
--query 'LoadBalancers[0].DNSName' --output text)
aws elbv2 create-listener \
--load-balancer-arn $ALB_ARN \
--protocol HTTP --port 80 \
--default-actions Type=forward,TargetGroupArn=$TG_ARN
We put the ALB in the public subnets so it gets a DNS name that's reachable, but alb-sg still controls who can actually connect to it. We grab $ALB_DNS here because we'll need it in the next step to set up the Route 53 record.
Step 8: Create the Route 53 Private Hosted Zone
# Comment="" is required in LocalStack - optional on real AWS
ZONE_ID=$(aws route53 create-hosted-zone \
--name internal.example.com \
--caller-reference $(date +%s) \
--hosted-zone-config Comment="",PrivateZone=true \
--vpc VPCRegion=us-east-1,VPCId=$VPC_ID \
--query 'HostedZone.Id' --output text | cut -d'/' -f3)
A private hosted zone means only resources inside the associated VPC can resolve internal.example.com. Nothing outside the VPC will be able to look it up. One thing to watch out for: Comment="" is required in LocalStack, even though on real AWS you can skip it.
Step 9: Add DNS Record Pointing to the ALB
aws route53 change-resource-record-sets \
--hosted-zone-id $ZONE_ID \
--change-batch '{
"Changes": [{
"Action": "CREATE",
"ResourceRecordSet": {
"Name": "app.internal.example.com",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [{"Value": "'"$ALB_DNS"'"}]
}
}]
}'
This CNAME record maps our clean internal name to the ALB's auto-generated hostname. Now any service inside the VPC can hit app.internal.example.com instead of having to know the ALB's ugly auto-generated DNS name.
Step 10: Verify
# VPC exists
aws ec2 describe-vpcs --filters "Name=tag:Name,Values=app-vpc" \
--query 'Vpcs[0].VpcId' --output text
# ALB is active
aws elbv2 describe-load-balancers --names app-alb \
--query 'LoadBalancers[0].State.Code' --output text
# Instances are registered
aws elbv2 describe-target-health --target-group-arn $TG_ARN
# DNS record exists
aws route53 list-resource-record-sets --hosted-zone-id $ZONE_ID \
--query "ResourceRecordSets[?Name=='app.internal.example.com.']"
Quick sanity check to make sure everything is actually in place: the VPC, the load balancer, the instances, and the DNS record.