The most surprising thing about designing a secure AWS VPC is that your subnets, NACLs, and Security Groups aren’t just firewalls; they’re fundamental building blocks that dictate network flow and access at different layers.
Let’s spin up a simple, secure VPC. We’ll create two public subnets and two private subnets, each in a different Availability Zone for resilience.
First, the VPC itself. We’ll use a standard private IP range, say 10.0.0.0/16.
aws ec2 create-vpc --cidr-block 10.0.0.0/16 --query 'Vpc.VpcId' --output text
Let’s call this vpc-0123456789abcdef0.
Now, the subnets. We need public ones for our bastion hosts or load balancers, and private ones for our application servers and databases.
# Public subnet 1 (AZ-a)
aws ec2 create-subnet --vpc-id vpc-0123456789abcdef0 --cidr-block 10.0.1.0/24 --availability-zone us-east-1a --query 'Subnet.SubnetId' --output text
# Public subnet 2 (AZ-b)
aws ec2 create-subnet --vpc-id vpc-0123456789abcdef0 --cidr-block 10.0.2.0/24 --availability-zone us-east-1b --query 'Subnet.SubnetId' --output text
# Private subnet 1 (AZ-a)
aws ec2 create-subnet --vpc-id vpc-0123456789abcdef0 --cidr-block 10.0.11.0/24 --availability-zone us-east-1a --query 'Subnet.SubnetId' --output text
# Private subnet 2 (AZ-b)
aws ec2 create-subnet --vpc-id vpc-0123456789abcdef0 --cidr-block 10.0.12.0/24 --availability-zone us-east-1b --query 'Subnet.SubnetId' --output text
We’ll tag them for clarity: subnet-public-a, subnet-public-b, subnet-private-a, subnet-private-b.
To make subnets public, they need a route to an Internet Gateway. We’ll create one and attach it to our VPC.
aws ec2 create-internet-gateway --query 'InternetGateway.InternetGatewayId' --output text
aws ec2 attach-internet-gateway --vpc-id vpc-0123456789abcdef0 --internet-gateway-id igw-0abcdef1234567890
Then, we create a route table for the public subnets and add a default route to the Internet Gateway.
aws ec2 create-route-table --vpc-id vpc-0123456789abcdef0 --query 'RouteTable.RouteTableId' --output text
# Associate public subnet 1
aws ec2 associate-route-table --route-table-id rtb-0123456789abcdef0 --subnet-id subnet-public-a
# Associate public subnet 2
aws ec2 associate-route-table --route-table-id rtb-0123456789abcdef0 --subnet-id subnet-public-b
# Add default route
aws ec2 create-route --route-table-id rtb-0123456789abcdef0 --destination-cidr-block 0.0.0.0/0 --gateway-id igw-0abcdef1234567890
Now, let’s consider Network Access Control Lists (NACLs). NACLs are stateless, meaning you need to define both inbound and outbound rules. They operate at the subnet level. We’ll use the default NACL for now, which allows all traffic, but in a real-world scenario, you’d create custom NACLs.
For a more granular approach, we have Security Groups. These are stateful and operate at the instance level. They act as a virtual firewall for your EC2 instances.
Let’s create a Security Group for our web servers in the public subnets. We want to allow HTTP (port 80) and HTTPS (port 443) from anywhere, and SSH (port 22) from a specific bastion host IP.
aws ec2 create-security-group --group-name sg-webserver --description "Security group for web servers" --vpc-id vpc-0123456789abcdef0 --query 'GroupId' --output text
# Allow HTTP
aws ec2 authorize-security-group-ingress --group-id sg-webserver --protocol tcp --port 80 --cidr 0.0.0.0/0
# Allow HTTPS
aws ec2 authorize-security-group-ingress --group-id sg-webserver --protocol tcp --port 443 --cidr 0.0.0.0/0
# Allow SSH from bastion (replace with your bastion's actual IP)
aws ec2 authorize-security-group-ingress --group-id sg-webserver --protocol tcp --port 22 --cidr 192.168.1.100/32
Now, a Security Group for our private application servers. They should only allow traffic from the web servers on a specific application port (e.g., 8080).
aws ec2 create-security-group --group-name sg-appserver --description "Security group for app servers" --vpc-id vpc-0123456789abcdef0 --query 'GroupId' --output text
# Allow traffic from web servers on port 8080
aws ec2 authorize-security-group-ingress --group-id sg-appserver --protocol tcp --port 8080 --source-group sg-webserver
Finally, a Security Group for our database servers. They should only allow traffic from the application servers on the database port (e.g., 3306 for MySQL).
aws ec2 create-security-group --group-name sg-dbserver --description "Security group for database servers" --vpc-id vpc-0123456789abcdef0 --query 'GroupId' --output text
# Allow traffic from app servers on port 3306
aws ec2 authorize-security-group-ingress --group-id sg-dbserver --protocol tcp --port 3306 --source-group sg-appserver
Notice how the sg-appserver rule uses --source-group sg-webserver, and sg-dbserver uses --source-group sg-appserver. This is the power of referencing Security Groups as sources, allowing for dynamic security policies as your infrastructure scales.
When designing your VPC, remember that NACLs are the stateless gatekeepers for your subnets, and Security Groups are the stateful guardians for your instances. A common pattern is to deny broad access at the NACL level and then allow specific access at the Security Group level for your instances. For instance, you might have an NACL rule denying all inbound traffic by default and then explicitly allow specific ports for your public subnets.
The next step in securing your VPC is often implementing NAT Gateways or NAT Instances to allow instances in private subnets to initiate outbound connections to the internet without being directly accessible from it.