How To

Create AWS VPC Network with CloudFormation

AWS CloudFormation lets you define your entire VPC network as code – subnets, route tables, NAT gateways, security groups, and NACLs – all in a single YAML template. You deploy it once, and CloudFormation builds everything in the right order with proper dependencies. No clicking through the console, no missed configurations, and full rollback if something fails.

Original content from computingforgeeks.com - post 72666

This guide walks through building a production-grade AWS VPC network from scratch using CloudFormation. We start with the VPC and Internet Gateway, add public and private subnets across multiple Availability Zones, configure NAT Gateways for outbound traffic, and lock things down with security groups and Network ACLs. Every template is deployment-ready and follows AWS best practices for high availability.

Prerequisites

Before you start, make sure you have the following in place:

  • An AWS account with permissions to create VPC resources (VPC, subnets, route tables, NAT Gateways, security groups, NACLs) and CloudFormation stacks
  • AWS CLI installed and configured with valid credentials
  • A text editor for writing YAML templates
  • Basic understanding of IP addressing, CIDR notation, and subnetting

Step 1: Design the VPC Network Architecture

Before writing any CloudFormation template, plan the network layout. A well-designed VPC uses a CIDR block large enough for growth, splits subnets across multiple Availability Zones for high availability, and separates public-facing resources from private ones.

Here is the architecture we will build:

ComponentCIDR / Details
VPC10.0.0.0/16 (65,536 IPs)
Public Subnet AZ-a10.0.1.0/24 (256 IPs)
Public Subnet AZ-b10.0.2.0/24 (256 IPs)
Private Subnet AZ-a10.0.10.0/24 (256 IPs)
Private Subnet AZ-b10.0.20.0/24 (256 IPs)
Internet GatewayPublic subnet internet access
NAT GatewayPrivate subnet outbound access

The /16 VPC gives you room to add more subnets later – for databases, caches, or additional AZs. Each /24 subnet provides 251 usable IPs (AWS reserves 5 per subnet). Public subnets hold load balancers and bastion hosts. Private subnets hold application servers and databases that should never be directly reachable from the internet.

Step 2: Create VPC and Internet Gateway Template

Start with the foundation – the VPC itself and an Internet Gateway that enables public subnet resources to reach the internet. Create a file called vpc-network.yaml with the following template:

AWSTemplateFormatVersion: '2010-09-09'
Description: Production VPC network with public and private subnets across two AZs

Parameters:
  EnvironmentName:
    Type: String
    Default: production
    Description: Environment name used for resource tagging

  VpcCIDR:
    Type: String
    Default: 10.0.0.0/16
    Description: CIDR block for the VPC

Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCIDR
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-vpc'

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-igw'

  InternetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC

The EnableDnsSupport and EnableDnsHostnames settings are important – without them, instances inside the VPC cannot resolve public DNS names or get public hostnames. The Internet Gateway must be explicitly attached to the VPC through a VPCGatewayAttachment resource.

Step 3: Add Public Subnets with Route Table

Public subnets need a route table that sends internet-bound traffic (0.0.0.0/0) to the Internet Gateway. We create two public subnets in separate AZs for redundancy. Add the following resources to the same template under the Resources section:

  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [0, !GetAZs '']
      CidrBlock: 10.0.1.0/24
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-public-subnet-az1'

  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [1, !GetAZs '']
      CidrBlock: 10.0.2.0/24
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-public-subnet-az2'

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-public-rt'

  DefaultPublicRoute:
    Type: AWS::EC2::Route
    DependsOn: InternetGatewayAttachment
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  PublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet1

  PublicSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet2

The MapPublicIpOnLaunch: true setting automatically assigns public IPs to instances launched in these subnets. The DependsOn: InternetGatewayAttachment on the route is critical – it tells CloudFormation to wait until the gateway is attached before creating the route. Without it, the stack can fail during creation.

The !Select [0, !GetAZs ''] intrinsic function picks the first available AZ in the region, while !Select [1, !GetAZs ''] picks the second. This makes the template portable across regions without hardcoding AZ names.

Step 4: Add Private Subnets with NAT Gateway

Private subnets host resources that should not be directly accessible from the internet – application servers, databases, cache clusters. These resources still need outbound internet access for package updates and API calls, so we route their traffic through a NAT Gateway placed in a public subnet.

  NatGatewayEIP:
    Type: AWS::EC2::EIP
    DependsOn: InternetGatewayAttachment
    Properties:
      Domain: vpc
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-nat-eip'

  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatGatewayEIP.AllocationId
      SubnetId: !Ref PublicSubnet1
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-nat-gw'

  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [0, !GetAZs '']
      CidrBlock: 10.0.10.0/24
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-private-subnet-az1'

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [1, !GetAZs '']
      CidrBlock: 10.0.20.0/24
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-private-subnet-az2'

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-private-rt'

  DefaultPrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGateway

  PrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      SubnetId: !Ref PrivateSubnet1

  PrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      SubnetId: !Ref PrivateSubnet2

The NAT Gateway needs an Elastic IP and must be placed in a public subnet – it translates private IP addresses to the EIP for outbound traffic. Note that NAT Gateways are not free – AWS charges per hour and per GB of data processed. For production, consider adding a second NAT Gateway in the other AZ for redundancy. A single NAT Gateway is a single point of failure.

Step 5: Configure Security Groups

Security groups act as virtual firewalls at the instance level. They are stateful – if you allow inbound traffic on a port, the return traffic is automatically allowed. Define separate security groups for each tier of your application. Add these to your template:

  WebServerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Allow HTTP, HTTPS, and SSH access
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0
        - IpProtocol: tcp
          FromPort: 22
          ToPort: 22
          CidrIp: 10.0.0.0/16
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-web-sg'

  DatabaseSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Allow database access from application tier only
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3306
          ToPort: 3306
          SourceSecurityGroupId: !Ref WebServerSecurityGroup
        - IpProtocol: tcp
          FromPort: 5432
          ToPort: 5432
          SourceSecurityGroupId: !Ref WebServerSecurityGroup
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-db-sg'

The web server security group allows HTTP (80) and HTTPS (443) from anywhere, while SSH (22) is restricted to the VPC CIDR – you access servers through a bastion host, not directly from the internet. The database security group references the web server security group instead of CIDR blocks. This means only instances attached to the web server security group can reach the database ports. If you add or remove web servers, the rule adjusts automatically.

Step 6: Add Network ACLs

Network ACLs provide a second layer of defense at the subnet level. Unlike security groups, NACLs are stateless – you must define both inbound and outbound rules explicitly. They evaluate rules in order by rule number, and the first matching rule wins.

  PublicNetworkAcl:
    Type: AWS::EC2::NetworkAcl
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub '${EnvironmentName}-public-nacl'

  PublicInboundHTTP:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref PublicNetworkAcl
      RuleNumber: 100
      Protocol: 6
      RuleAction: allow
      CidrBlock: 0.0.0.0/0
      PortRange:
        From: 80
        To: 80

  PublicInboundHTTPS:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref PublicNetworkAcl
      RuleNumber: 110
      Protocol: 6
      RuleAction: allow
      CidrBlock: 0.0.0.0/0
      PortRange:
        From: 443
        To: 443

  PublicInboundSSH:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref PublicNetworkAcl
      RuleNumber: 120
      Protocol: 6
      RuleAction: allow
      CidrBlock: 10.0.0.0/16
      PortRange:
        From: 22
        To: 22

  PublicInboundEphemeral:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref PublicNetworkAcl
      RuleNumber: 130
      Protocol: 6
      RuleAction: allow
      CidrBlock: 0.0.0.0/0
      PortRange:
        From: 1024
        To: 65535

  PublicOutboundAll:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref PublicNetworkAcl
      RuleNumber: 100
      Protocol: -1
      Egress: true
      RuleAction: allow
      CidrBlock: 0.0.0.0/0

  PublicSubnet1NaclAssociation:
    Type: AWS::EC2::SubnetNetworkAclAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1
      NetworkAclId: !Ref PublicNetworkAcl

  PublicSubnet2NaclAssociation:
    Type: AWS::EC2::SubnetNetworkAclAssociation
    Properties:
      SubnetId: !Ref PublicSubnet2
      NetworkAclId: !Ref PublicNetworkAcl

The ephemeral port range (1024-65535) is essential for inbound NACLs. When your web server responds to an HTTP request, the return traffic arrives on a random high port. Without this rule, response traffic gets blocked and connections hang. For private subnets, the default VPC NACL allows all traffic – customize it based on your compliance requirements.

Step 7: Deploy the CloudFormation Stack

With the complete template ready, validate it first to catch syntax errors before deployment. Run the following command to check the template:

aws cloudformation validate-template --template-body file://vpc-network.yaml

If validation passes with no errors, you will see the template parameters and description in the output. Now deploy the stack:

aws cloudformation create-stack \
  --stack-name production-vpc \
  --template-body file://vpc-network.yaml \
  --parameters ParameterKey=EnvironmentName,ParameterValue=production \
  --tags Key=Project,Value=infrastructure Key=ManagedBy,Value=cloudformation

CloudFormation returns the stack ID immediately. The actual resource creation happens asynchronously. Monitor the progress with this command:

aws cloudformation wait stack-create-complete --stack-name production-vpc

This command blocks until the stack reaches CREATE_COMPLETE or fails. A typical VPC stack takes 2-4 minutes. If it fails, CloudFormation automatically rolls back all created resources – you do not end up with half-built infrastructure.

You can also watch the event stream in real time to see each resource as it gets created:

aws cloudformation describe-stack-events \
  --stack-name production-vpc \
  --query 'StackEvents[*].[Timestamp,ResourceType,LogicalResourceId,ResourceStatus]' \
  --output table

The output shows each resource creation event with its status – look for CREATE_COMPLETE on all resources to confirm everything deployed correctly.

Step 8: Verify the Deployed VPC Resources

After the stack finishes, verify that all resources were created correctly. First, check the stack outputs and status:

aws cloudformation describe-stacks \
  --stack-name production-vpc \
  --query 'Stacks[0].StackStatus'

The response should return CREATE_COMPLETE. Next, verify the VPC and subnets exist in your account:

aws ec2 describe-vpcs \
  --filters "Name=tag:Name,Values=production-vpc" \
  --query 'Vpcs[*].[VpcId,CidrBlock,State]' \
  --output table

This confirms the VPC was created with the correct CIDR block and is in the available state. Now check the subnets:

aws ec2 describe-subnets \
  --filters "Name=vpc-id,Values=$(aws ec2 describe-vpcs --filters 'Name=tag:Name,Values=production-vpc' --query 'Vpcs[0].VpcId' --output text)" \
  --query 'Subnets[*].[SubnetId,CidrBlock,AvailabilityZone,Tags[?Key==`Name`].Value|[0]]' \
  --output table

You should see all four subnets – two public and two private – each in a different Availability Zone with the correct CIDR blocks. Verify the route tables to confirm traffic routing is correct:

aws ec2 describe-route-tables \
  --filters "Name=vpc-id,Values=$(aws ec2 describe-vpcs --filters 'Name=tag:Name,Values=production-vpc' --query 'Vpcs[0].VpcId' --output text)" \
  --query 'RouteTables[*].[RouteTableId,Tags[?Key==`Name`].Value|[0],Routes[*].[DestinationCidrBlock,GatewayId,NatGatewayId]]' \
  --output table

The public route table should show a route to 0.0.0.0/0 through the Internet Gateway. The private route table should show a route to 0.0.0.0/0 through the NAT Gateway.

Step 9: Update and Delete CloudFormation Stacks

One of the main advantages of CloudFormation is that you can modify your infrastructure by updating the template and applying changes. CloudFormation figures out what changed and applies only the necessary modifications.

To add a third Availability Zone, add new subnet resources to the template and run an update:

aws cloudformation update-stack \
  --stack-name production-vpc \
  --template-body file://vpc-network.yaml \
  --parameters ParameterKey=EnvironmentName,ParameterValue=production

Before applying updates to production, use change sets to preview what CloudFormation will modify. This is especially important for network changes where a replacement could cause downtime:

aws cloudformation create-change-set \
  --stack-name production-vpc \
  --template-body file://vpc-network.yaml \
  --change-set-name add-third-az \
  --parameters ParameterKey=EnvironmentName,ParameterValue=production

Review the change set to see exactly which resources will be added, modified, or replaced:

aws cloudformation describe-change-set \
  --stack-name production-vpc \
  --change-set-name add-third-az \
  --query 'Changes[*].[ResourceChange.Action,ResourceChange.LogicalResourceId,ResourceChange.ResourceType,ResourceChange.Replacement]' \
  --output table

If the changes look correct, execute the change set:

aws cloudformation execute-change-set \
  --stack-name production-vpc \
  --change-set-name add-third-az

To delete the entire VPC stack and all its resources, use the delete-stack command. CloudFormation deletes resources in reverse dependency order:

aws cloudformation delete-stack --stack-name production-vpc

Deletion fails if any resource is in use – for example, if EC2 instances are running inside the VPC subnets. Terminate all dependent resources first before deleting the network stack.

Step 10: CloudFormation VPC Best Practices

As your infrastructure grows, a single monolithic template becomes hard to maintain. Here are patterns that work well in production environments.

Use Nested Stacks for Large Networks

Split your network into separate templates – one for the VPC and subnets, one for security groups, one for NACLs. The parent template references child templates stored in S3:

Resources:
  NetworkStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://s3.amazonaws.com/my-templates/vpc-subnets.yaml
      Parameters:
        EnvironmentName: !Ref EnvironmentName

  SecurityStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://s3.amazonaws.com/my-templates/security-groups.yaml
      Parameters:
        VpcId: !GetAtt NetworkStack.Outputs.VpcId

Nested stacks let different teams own different layers. The networking team manages the VPC template while the application team manages security groups.

Parameterize Everything

Use parameters for CIDR blocks, environment names, and AZ counts. This lets you reuse the same template across dev, staging, and production with different values:

Parameters:
  VpcCIDR:
    Type: String
    Default: 10.0.0.0/16
    AllowedPattern: '(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/(\d{1,2})'
    ConstraintDescription: Must be a valid CIDR block

  NumberOfAZs:
    Type: Number
    Default: 2
    AllowedValues: [2, 3]
    Description: Number of Availability Zones to use

The AllowedPattern constraint validates CIDR format at deployment time, preventing misconfigured networks before any resources are created.

Export Stack Outputs

Export VPC and subnet IDs so other stacks can reference them. Add an Outputs section to the template:

Outputs:
  VpcId:
    Description: VPC ID
    Value: !Ref VPC
    Export:
      Name: !Sub '${EnvironmentName}-VpcId'

  PublicSubnet1Id:
    Description: Public Subnet 1 ID
    Value: !Ref PublicSubnet1
    Export:
      Name: !Sub '${EnvironmentName}-PublicSubnet1Id'

  PrivateSubnet1Id:
    Description: Private Subnet 1 ID
    Value: !Ref PrivateSubnet1
    Export:
      Name: !Sub '${EnvironmentName}-PrivateSubnet1Id'

  WebServerSecurityGroupId:
    Description: Web Server Security Group ID
    Value: !Ref WebServerSecurityGroup
    Export:
      Name: !Sub '${EnvironmentName}-WebServerSGId'

Other stacks import these values with !ImportValue. For example, an EC2 stack can reference !ImportValue production-PublicSubnet1Id to place instances in the right subnet without hardcoding IDs. For more on using CloudFormation for other AWS services, check the AWS Application Load Balancer with CloudFormation guide and the AWS RDS MySQL with CloudFormation setup.

Enable VPC Flow Logs

Always enable VPC Flow Logs in production. They capture metadata about IP traffic flowing through your network interfaces – essential for security auditing, troubleshooting connectivity issues, and compliance. You can configure VPC Flow Logs to CloudWatch for real-time monitoring and alerting on suspicious traffic patterns.

Additional Recommendations

  • Use DeletionPolicy: Retain on the VPC and Elastic IPs to prevent accidental deletion of critical networking resources
  • Tag every resource consistently – at minimum include Name, Environment, and ManagedBy tags for cost tracking and automation
  • Plan CIDR blocks carefully – VPC CIDR blocks cannot be changed after creation (you can add secondary CIDRs, but the primary stays)
  • Avoid overlapping CIDRs if you plan to use VPC peering or Transit Gateway to connect multiple VPCs
  • Consider using cfn-lint and cfn-nag to validate your templates for syntax errors and security best practices before deployment
  • Store templates in version control (Git) and use CI/CD pipelines to deploy changes – never edit stacks through the console

Conclusion

You now have a production-ready VPC network defined entirely in CloudFormation – VPC with public and private subnets across two AZs, NAT Gateway for private outbound access, security groups for instance-level filtering, and NACLs for subnet-level defense. This template is your baseline for any AWS workload.

For production environments, add a second NAT Gateway for AZ redundancy, enable VPC Flow Logs for auditing, and integrate VPC Endpoints for private access to AWS services like S3 and DynamoDB without traversing the internet. Use change sets for every modification and keep your templates in version control.

Related Articles

AWS Running Docker Containers on AWS With ECS – Part 1 Cloud The Importance of Runtime Protection for Cloud-Native Applications Cloud Benefits of Edge Cloud Computing AlmaLinux Install OpenStack Yoga on Rocky Linux 8 / AlmaLinux 8 with Packstack

Leave a Comment

Press ESC to close