This post walks through standing up a custom VPC on AWS using CloudFormation — a 3-tier network across three availability zones with segmentation enforced at the security group level and a few security controls baked in from the start.
The full template is on GitLab at git.naxslabs.com/darnell/cloudformation.
Architecture
Three subnets, one per AZ, each representing a tier of the stack:
| Subnet | AZ | CIDR | Role |
|---|---|---|---|
| USEASTA | us-east-1a | 10.10.1.0/24 | Web tier — internet facing |
| USEASTB | us-east-1b | 10.10.2.0/24 | App tier — internal only |
| USEASTC | us-east-1c | 10.10.3.0/24 | Data tier — internal only |
Traffic flows one direction — internet hits the web tier, web tier talks to the app tier, app tier talks to the data tier.
The VPC
MyVPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: "10.10.0.0/16"
EnableDnsHostnames: True
EnableDnsSupport: True
InstanceTenancy: default
Tags:
- Key: Name
Value: NAXS-VPC
I’ve defined a custom VPC with a /16 CIDR block — 10.10.0.0/16 — which gives plenty of address space to carve into subnets for each tier. A couple of properties worth noting: EnableDnsHostnames and EnableDnsSupport both need to be true if you want instances to resolve AWS service endpoints by DNS. Leaving these off means instances inside the VPC can’t resolve DNS — and since managed services like RDS hand you a hostname to connect to rather than an IP, your application won’t be able to reach them at all. InstanceTenancy: default means instances run on shared hardware, which is correct for most workloads. Dedicated tenancy exists for specific compliance requirements but costs significantly more.
Internet Gateway
MyIGW:
Type: AWS::EC2::InternetGateway
MyIGWAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref MyVPC
InternetGatewayId: !Ref MyIGW
Here I’m creating an internet gateway and attaching it to the VPC. Without this, instances in the public subnet have no path to the internet. In CloudFormation these are two separate resources — the IGW and the attachment.
Route Table
MyRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref MyVPC
Tags:
- Key: Name
Value: naxs-public-rt
MyRoute:
Type: AWS::EC2::Route
DependsOn: MyIGWAttachment
Properties:
RouteTableId: !Ref MyRouteTable
DestinationCidrBlock: "0.0.0.0/0"
GatewayId: !Ref MyIGW
MyRTAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref USEASTA
RouteTableId: !Ref MyRouteTable
Now I’m creating a route table, adding a default route pointing all non-local traffic to the IGW, and associating it with the web tier subnet. The DependsOn: MyIGWAttachment on the route is often necessary as CloudFormation parallelizes resource creation and without it can attempt to create this route before the attachment completes, which fails. The route table, default route, and route association all need to be defined.
Subnets
USEASTA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref MyVPC
CidrBlock: "10.10.1.0/24"
AvailabilityZone: "us-east-1a"
MapPublicIpOnLaunch: true
USEASTB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref MyVPC
CidrBlock: "10.10.2.0/24"
AvailabilityZone: "us-east-1b"
MapPublicIpOnLaunch: false
USEASTC:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref MyVPC
CidrBlock: "10.10.3.0/24"
AvailabilityZone: "us-east-1c"
MapPublicIpOnLaunch: false
Three /24 subnets, one per availability zone. Each gets 251 usable addresses. MapPublicIpOnLaunch: true on the web tier means instances there get a public IP automatically — appropriate since that’s the internet-facing layer. The app and data tier subnets have this set to false. Those instances have no public IP and are not directly reachable from the internet.
The trade-off is that without a public IP those instances also have no outbound internet path — no package updates, no calls to external APIs. In production you’d place a NAT gateway in the public subnet and update the private subnet route tables to point outbound traffic through it. That gives the app and data tiers outbound internet access without exposing them to inbound connections from outside the VPC.
Security Groups
Four security groups — one for external SSH into the web tier, then one per tier chaining access down the stack.
SSH — web tier only
MySecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Allow SSH Access
GroupName: naxs-ssh-sg
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: 0.0.0.0/0
Tags:
- Key: Name
Value: naxs-ssh-sg
VpcId: !Ref MyVPC
SSH open to the world is intentional here for demo purposes. In production, lock this to a specific CIDR or replace SSH entirely with SSM Session Manager — no open port, no key management, full session logging to CloudTrail.
Web tier — internet facing
WebSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Web tier - internet facing
GroupName: naxs-web-sg
VpcId: !Ref MyVPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: 0.0.0.0/0
Tags:
- Key: Name
Value: naxs-web-sg
The web tier accepts HTTP and HTTPS from anywhere. This is the only security group with open inbound access from the internet.
App tier — web tier access only
AppSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: App tier - web tier access only
GroupName: naxs-app-sg
VpcId: !Ref MyVPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 8080
ToPort: 8080
SourceSecurityGroupId: !Ref WebSG
- IpProtocol: tcp
FromPort: 22
ToPort: 22
SourceSecurityGroupId: !Ref WebSG
Tags:
- Key: Name
Value: naxs-app-sg
Data tier — app tier access only
DataSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Data tier - app tier access only
GroupName: naxs-data-sg
VpcId: !Ref MyVPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
SourceSecurityGroupId: !Ref AppSG
- IpProtocol: tcp
FromPort: 22
ToPort: 22
SourceSecurityGroupId: !Ref AppSG
Tags:
- Key: Name
Value: naxs-data-sg
The key thing here is SourceSecurityGroupId rather than a CIDR range. The app tier isn’t trusting an IP — it’s trusting a security group identity. Anything that doesn’t have WebSG attached gets nothing, regardless of where it sits in the network. That’s a stronger control than IP-based rules because it follows the resource, not the address.
SSH access between tiers is included here for demonstration only — it lets us hop through the stack and verify the segmentation is working. In production there would be no SSH between tiers at all; the app tier talks to the data tier on the database port only. The only valid path in is your machine → web tier → app tier → data tier. Try to SSH directly from your machine to the data tier, or skip from the web tier straight to the data tier — it times out. The data tier security group only accepts traffic from the app tier, nothing else.
EC2 Instances
InstanceEastA:
Type: AWS::EC2::Instance
Properties:
SubnetId: !Ref USEASTA
InstanceType: t2.micro
ImageId: "ami-0ea87431b78a82070"
KeyName: "myectwos"
SecurityGroupIds:
- !Ref MySecurityGroup
- !Ref WebSG
MetadataOptions:
HttpTokens: required
HttpEndpoint: enabled
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeSize: 8
VolumeType: gp3
Encrypted: true
InstanceEastB:
Type: AWS::EC2::Instance
Properties:
SubnetId: !Ref USEASTB
InstanceType: t2.micro
ImageId: "ami-0ea87431b78a82070"
KeyName: "myectwos"
SecurityGroupIds:
- !Ref AppSG
MetadataOptions:
HttpTokens: required
HttpEndpoint: enabled
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeSize: 8
VolumeType: gp3
Encrypted: true
InstanceEastC:
Type: AWS::EC2::Instance
Properties:
SubnetId: !Ref USEASTC
InstanceType: t2.micro
ImageId: "ami-0ea87431b78a82070"
KeyName: "myectwos"
SecurityGroupIds:
- !Ref DataSG
MetadataOptions:
HttpTokens: required
HttpEndpoint: enabled
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeSize: 8
VolumeType: gp3
Encrypted: true
One instance per subnet, each assigned its tier’s security group. InstanceEastA gets both the SSH group and WebSG since it’s the public entry point. The other two only get their tier group — no direct SSH from the internet.
MetadataOptions enforces IMDSv2 on the instance metadata service. Every EC2 instance exposes a metadata endpoint at 169.254.169.254 — credentials, instance info, IAM role data. IMDSv1 requires no authentication, so any process on the box can query it freely, including an attacker who found an SSRF vulnerability in your app. IMDSv2 requires a session token obtained via a PUT request first, which most SSRF exploits can’t do. One line, meaningful reduction in attack surface.
Setting Encrypted: true on the EBS volume without specifying a KmsKeyId automatically uses the default AWS-managed EBS key — no KMS configuration needed. The volume is encrypted at rest, data in transit between the instance and the volume is encrypted, and any snapshots will be encrypted too. No performance penalty, no extra cost, no reason to leave it off.
Deploying
aws cloudformation create-stack \
--stack-name naxs-vpc \
--template-body file://template.yaml \
--region us-east-1
Watch the stack come up:
aws cloudformation describe-stack-events \
--stack-name naxs-vpc \
--query 'StackEvents[*].[LogicalResourceId,ResourceStatus]' \
--output table
Tear it down when done:
aws cloudformation delete-stack --stack-name naxs-vpc
Three t2.micro instances fall under the AWS Free Tier for the first 12 months. The internet gateway has no hourly charge — data transfer through it does. Delete the stack when you’re finished to avoid charges.
Full template: git.naxslabs.com/darnell