Deploying a 3-Tier AWS VPC — Infrastructure as Code from Day One

Deploying a 3-Tier AWS VPC — Infrastructure as Code from Day One

3-Tier VPC on AWS with CloudFormation — NAXS Labs

3-Tier VPC on AWS
with CloudFormation

Standing up a custom VPC with CloudFormation.

All posts

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:

SubnetAZCIDRRole
USEASTAus-east-1a10.10.1.0/24Web tier — internet facing
USEASTBus-east-1b10.10.2.0/24App tier — internal only
USEASTCus-east-1c10.10.3.0/24Data 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
Lab only

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
Cost

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

© 2026 NAXS Labs LLC · Providence, RI
© 2026 NAXS Labs LLC · Providence, RI

NAXS Labs
Logo