Create a Secure VPC with SSM-Managed Private EC2 Instances Using the AWS CLI
In this blog post, we will walk through how to create a secure VPC in AWS with EC2 instances in private subnets that are managed by Systems Manager (SSM), are in an Auto Scaling Group (ASG), and are fronted by an Application Load Balancer (ALB), all using the AWS CLI. This setup will allow you to run your applications in a secure, highly available, fault-tolerant, and scalable environment.
I’m going to assume that you have a basic understanding of what a VPC, a subnet (private or public), security groups, Network Access Control Lists (NACLs), an ALB, an ASG, an Internet Gateway (IGW), and a NAT Gateway are.
That said, SSM is a service that helps you automate and manage your AWS resources at scale. You can use SSM to run commands, patch software, configure settings, and monitor the performance of your EC2 instances.
To create a secure VPC with EC2 instances in private subnets that are managed by SSM and fronted by an ALB, we’ll need to perform the steps listed below (you’ll need to have the AWS CLI installed and configured for your account to be able to follow along)
We’ll start by creating a VPC with four subnets: two public subnets and two private subnets in two AZs. This VPC will have a CIDR block of 10.0.0.0/16:
aws ec2 create-vpc --cidr-block 10.0.0.0/16
Then we’ll create an IGW and attach it to our VPC so that the VPC will be able to communicate with the Internet:
aws ec2 create-internet-gateway
aws ec2 attach-internet-gateway --internet-gateway-id igw-xxxxxxxx --vpc-id vpc-xxxxxxxx
Note that you would have to replace igw-xxxxxxxx and vpc-xxxxxxxx respectively with the IDs of the IGW and VPC we would have previously created.
We’ll then create four subnets in our VPC, two in the us-east-1a Availability Zone (AZ) and two in the us-east-1b AZ (I’m creating the resources in the N. Virginia region, us-east-1). They’ll have the CIDR blocks 10.0.0.0/24, 10.0.1.0/24, 10.0.2.0/24 and 10.0.3.0/24:
aws ec2 create-subnet --vpc-id vpc-xxxxxxxx --cidr-block 10.0.0.0/24 --availability-zone us-east-1a
aws ec2 create-subnet --vpc-id vpc-xxxxxxxx --cidr-block 10.0.1.0/24 --availability-zone us-east-1b
aws ec2 create-subnet --vpc-id vpc-xxxxxxxx --cidr-block 10.0.2.0/24 --availability-zone us-east-1a
aws ec2 create-subnet --vpc-id vpc-xxxxxxxx --cidr-block 10.0.3.0/24 --availability-zone us-east-1b
A prerequisite to allow SSM to manage our private EC2 instances is to create VPC interface endpoints, and associate them to our private subnets. Note that the private DNS setting needs to be enabled to allow communication with the SSM service:
aws ec2 create-vpc-endpoint --vpc-endpoint-type Interface --vpc-id vpc-xxxxxxxx --service-name com.amazonaws.us-east-1.ssm --subnet-ids subnet-33333333 subnet-44444444 --private-dns-enabled
aws ec2 create-vpc-endpoint --vpc-endpoint-type Interface --vpc-id vpc-xxxxxxxx --service-name com.amazonaws.us-east-1.ssmmessages --subnet-ids subnet-33333333 subnet-44444444 --private-dns-enabled
aws ec2 create-vpc-endpoint --vpc-endpoint-type Interface --vpc-id vpc-xxxxxxxx --service-name com.amazonaws.us-east-1.ec2messages --subnet-ids subnet-33333333 subnet-44444444 --private-dns-enabled
In order to secure our subnets, we’ll create NACLs for the private and public subnets, and attach them to their respective subnets.
We’ll start by creating the NACLs for the public and private subnets:
aws ec2 create-network-acl --vpc-id vpc-xxxxxxxx
aws ec2 create-network-acl --vpc-id vpc-xxxxxxxx
We’ll then create two entries that respectively allow inbound traffic from the Internet and outbound traffic for the Internet, for the public subnet:
aws ec2 create-network-acl-entry --network-acl-id acl-11111111 --ingress --rule-number 100 --protocol tcp --cidr-block 0.0.0.0/0 --rule-action allow
aws ec2 create-network-acl-entry --network-acl-id acl-11111111 --egress --rule-number 100 --protocol tcp --cidr-block 0.0.0.0/0 --rule-action allow
We then create entries that respectively allow inbound traffic from the ALB and outbound traffic to the ephemeral ports (make sure to replace the CIDR block value with the IP address of the ALB after creating it):
aws ec2 create-network-acl-entry --network-acl-id acl-22222222 --ingress --rule-number 100 --protocol tcp --port-range From=443,To=443 --cidr-block x.x.x.x/32 --rule-action allow
aws ec2 create-network-acl-entry --network-acl-id acl-22222222 --egress --rule-number 100 --protocol tcp --port-range From=443,To=443 --cidr-block 0.0.0.0/0 --rule-action allow
aws ec2 create-network-acl-entry --network-acl-id acl-22222222 --egress --rule-number 200 --protocol tcp --port-range From=1024,To=65535 --cidr-block x.x.x.x/32 --rule-action allow
Next, we allocate two Elastic IP addresses for the NAT gateways that will be provisioned in our public subnets:
aws ec2 allocate-address --domain vpc --output text --query ‘AllocationId’
aws ec2 allocate-address --domain vpc --output text --query ‘AllocationId’
These commands return the allocation IDs of the Elastic IP addresses, such as eipalloc-11111111 and eipalloc-22222222.
We’ll then create a NAT Gateway in each of our public subnets:
aws ec2 create-nat-gateway --subnet-id subnet-11111111 --allocation-id eipalloc-11111111 --output text --query ‘NatGateway.NatGatewayId’
aws ec2 create-nat-gateway --subnet-id subnet-22222222 --allocation-id eipalloc-22222222 --output text --query ‘NatGateway.NatGatewayId’
These commands return the NAT gateway IDs, such as nat-xxxxxxxxxxxxxxxx and nat-yyyyyyyyyyyyyyyy.
We’ll wait for our newly created NAT Gateways to become available before proceeding. While waiting, we can check the status of the NAT Gateways by running the following command:
aws ec2 describe-nat-gateways --nat-gateway-ids nat-xxxxxxxxxxxxxxxx nat-yyyyyyyyyyyyyyyy --output table
The command returns a table with information about the NAT gateways, such as their state, subnet ID, and elastic IP address. When the state of both NAT gateways is available, you can proceed to the next step.
We’ll then create a route table for the private subnets and add routes to the NAT gateways:
aws ec2 create-route-table --vpc-id vpc-xxxxxxxx --output text --query ‘RouteTable.RouteTableId’
aws ec2 create-route --route-table-id rtb-11111111 --destination-cidr-block 0.0.0.0/0 --nat-gateway-id nat-xxxxxxxxxxxxxxxx
aws ec2 create-route --route-table-id rtb-11111111 --destination-cidr-block 0.0.0.0/0 --nat-gateway-id nat-yyyyyyyyyyyyyyyy
The first command creates a route table for the VPC with ID vpc-xxxxxxxx and returns the route table ID, such as rtb-11111111. The second and third commands add routes to the NAT gateways for all destinations (0.0.0.0/0) in the route table.
Next, we’ll associate the route table with the private subnets:
aws ec2 associate-route-table --route-table-id rtb-11111111 --subnet-id subnet-33333333
aws ec2 associate-route-table --route-table-id rtb-11111111 --subnet-id subnet-44444444
These commands will allow resources provisioned in the private subnets to make requests to the Internet through the NAT Gateway, but they won’t be reachable from the Internet.
Next, we’ll create a route table and associate it with our public subnets:
aws ec2 create-route-table --vpc-id vpc-xxxxxxxx
aws ec2 associate-route-table --route-table-id rtb-22222222 --subnet-id subnet-11111111
aws ec2 associate-route-table --route-table-id rtb-22222222 --subnet-id subnet-22222222
We’ll then create a route in the route table that points to the internet gateway:
aws ec2 create-route --route-table-id rtb-22222222 --destination-cidr-block 0.0.0.0/0 --gateway-id igw-xxxxxxxx
The next thing we’ll do is create an ALB that will redirect traffic to our private EC2 instances in an ASG. For this, we’ll first need to create a public security group that will allow inbound HTTPS traffic (port 443) from the Internet (0.0.0.0/0). For convenience, our created security group will have the ID sg-11111111:
aws ec2 create-security-group --group-name alb-sg --description "Security group for ALB" --vpc-id vpc-xxxxxxxx
aws ec2 authorize-security-group-ingress --group-id sg-11111111 --protocol tcp --port 443 --cidr 0.0.0.0/0
We’ll then create our ALB, without specifying its target groups (we’ll do this later):
aws elbv2 create-load-balancer --name alb --type application --subnets subnet-11111111 subnet-22222222 --security-groups sg-11111111 --scheme internet-facing
Given that our ALB needs to forward traffic to target groups, we’ll create a security group for our private EC2 instances that will be in an ASG, which will serve as our ALB’s target group:
aws ec2 create-security-group --group-name private-sg --description "Security group for private EC2 instances" --vpc-id vpc-xxxxxxxx
Next, we’ll add rules to our private security group (with ID sg-22222222 for convenience) that allow inbound traffic from our ALB on port 80 (HTTP) and port 443 (HTTPS):
aws ec2 authorize-security-group-ingress --group-id sg-22222222 --protocol tcp --port 80 --source-group arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb/xxxxxxxxxxxxxxxx
aws ec2 authorize-security-group-ingress --group-id sg-22222222 --protocol tcp --port 443 --source-group arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb/xxxxxxxxxxxxxxxx
Then we’ll add rules to the security group that allow outbound traffic to the internet (CIDR block 0.0.0.0/0) on port 80 (HTTP) and port 443 (HTTPS):
aws ec2 authorize-security-group-egress --group-id sg-22222222 --protocol tcp --port 80 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-egress --group-id sg-22222222 --protocol tcp --port 443 --cidr 0.0.0.0/0
In order to allow our EC2 instances to be managed by SSM, we’ll need to create an IAM role which will be used as the EC2 instance profile.
First, we’ll create a trust policy document that allows SSM to assume the role. You can use the following JSON template and save it as trustpolicy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ssm.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
Then we’ll create an IAM role called EC2RoleForSSM using the trust policy document:
aws iam create-role --role-name EC2RoleForSSM --assume-role-policy-document file://trustpolicy.json
Then we’ll attach the AWS-managed policy, AmazonSSMManagedInstanceCore, to the IAM role that grants SSM the necessary permissions to manage our EC2 instances:
aws iam attach-role-policy --role-name EC2RoleForSSM --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
Next, we’ll create an instance profile (ec2-ssm-profile) for our IAM role EC2RoleForSSM, and add the IAM role to the instance profile:
aws iam create-instance-profile --instance-profile-name ec2-ssm-profile
aws iam add-role-to-instance-profile --instance-profile-name ec2-ssm-profile --role-name EC2RoleForSSM
We’ll then create a launch template that specifies the configuration of our private EC2 instances, such as their AMI ID, instance type, instance profile, and security group. We won’t create or use an SSH key pair because we’ll manage our instances using SSM instead.
We’ll use the latest version of the Amazon Linux 2023 AMI in the N. Virginia region (us-east-1), because it comes with an SSM agent preinstalled (its ID at the time of writing is ami-0889a44b331db0194), and call this launch template private-launch-template:
aws ec2 create-launch-template \
--launch-template-name private-launch-template \
--version-description "Initial version" \
--launch-template-data \
'{"ImageId":"ami-0889a44b331db0194","InstanceType":"t2.micro", "IamInstanceProfile": {"Name": "ec2-ssm-profile"},"SecurityGroupIds":["sg-11111111"]}'
We can now create our ASG that will use the launch template we just created, and will specify the desired capacity, minimum size, maximum size, and availability zones. We’ll need to provide a name for the ASG (private-asg) and a health check type (ELB in this case, so as to use the ALB’s health checks). We’ll assume our created launch template’s ID is lt-xxxxxxxxxxxxxxxx:
aws autoscaling create-auto-scaling-group \
--auto-scaling-group-name private-asg \
--launch-template "LaunchTemplateId=lt-xxxxxxxxxxxxxxxx,Version=1" \
--min-size 2 \
--max-size 4 \
--desired-capacity 2 \
--availability-zones us-east-1a us-east-1b \
--health-check-type ELB
The next step will be to create our ALB’s target group (alb-tg) which will listen on port 443 (HTTPS):
aws elbv2 create-target-group \
--name alb-tg \
--protocol HTTP \
--port 443 \
--vpc-id vpc-xxxxxxxx
Finally, we’ll attach our target group alb-tg to our previously created ASG. Be sure to use the right Amazon Resource Name (ARN) for the target group we just created:
aws autoscaling attach-load-balancer-target-groups \
--auto-scaling-group-name private-asg \
--target-group-arns arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/alb-tg/1234567890abcdef
Having created our ALB and target groups and their respective security groups, we’ll now create a listener that forwards requests on port 443 of the ALB to our target group. We’ll assume that we had previously requested a certificate from ACM and its ARN is arn:aws:acm:us-east-1:123456789012:certificate/abcdef12–3456–7890-abcd-ef1234567890. :
aws elbv2 create-listener --load-balancer-arn arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/alb/xxxxxxxxxxxxxxxx --protocol HTTPS --port 443 --certificates CertificateArn=arn:aws:acm:us-east-1:123456789012:certificate/abcdef12-3456-7890-abcd-ef1234567890 --default-actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/alb-tg/xxxxxxxxxxxxxxxx
We can now test our setup by accessing our ALB’s DNS name from a web browser. Since we haven’t installed any application on our servers, we’ll get an empty response.
Congratulations! You have successfully created a secure VPC in AWS with EC2 instances in private subnets that are managed by Systems Manager and fronted by an Application Load Balancer.
PS: Please feel free to leave questions or comments on how to improve the security of this VPC. We learn every day!