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.
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:
| Component | CIDR / Details |
|---|---|
| VPC | 10.0.0.0/16 (65,536 IPs) |
| Public Subnet AZ-a | 10.0.1.0/24 (256 IPs) |
| Public Subnet AZ-b | 10.0.2.0/24 (256 IPs) |
| Private Subnet AZ-a | 10.0.10.0/24 (256 IPs) |
| Private Subnet AZ-b | 10.0.20.0/24 (256 IPs) |
| Internet Gateway | Public subnet internet access |
| NAT Gateway | Private 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: Retainon 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.